@pikku/kysely 0.13.22 → 0.13.24

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,71 @@
1
+ ---
2
+ type: decision
3
+ title: Classification-driven encryption happens in a Kysely plugin
4
+ description: The existing column classification manifest drives a transparent query plugin, rather than encrypting the whole database file or asking call sites to encrypt
5
+ tags: [security, encryption, kysely, classification]
6
+ ---
7
+
8
+ # Classification-driven encryption happens in a Kysely plugin
9
+
10
+ Encrypting a local database at rest has two obvious shapes and both were
11
+ rejected. Encrypting the **whole file** on shutdown and decrypting on startup is
12
+ the simplest to describe and the easiest to lose data with: a crash, a battery
13
+ death, or two processes racing the same file leaves a half-written database with
14
+ no recovery story. Encrypting **every column** keeps the file safe but destroys
15
+ the database — you cannot `WHERE`, `ORDER BY`, or join on ciphertext, so the app
16
+ either reads whole tables into memory or stops using SQL.
17
+
18
+ The middle answer already exists in the repo. `classification.gen.ts` is a
19
+ per-column manifest of `private` / `secret` / `public` with a `form` of `plain`,
20
+ `hashed`, `wrapped` or `sealed`. Only `wrapped` and `sealed` columns are
21
+ encrypted; everything else stays queryable, which is most of the schema. The
22
+ manifest is generated from the hand-authored `db/annotations.ts` by
23
+ `pikku db migrate`, so the decision about a column is made once, in one place,
24
+ and the query layer follows it.
25
+
26
+ Enforcement belongs in a **Kysely plugin**, not at call sites. A call site that
27
+ forgets to encrypt writes plaintext into a column the manifest says is secret,
28
+ and nothing catches it — the row looks fine. A plugin sits in the query path
29
+ every write goes through, so "encrypted" becomes a property of the column rather
30
+ than a property of the developer's memory.
31
+
32
+ Transparency turned out to be **asymmetric**, and the implementation says so.
33
+ Reads are genuinely transparent: `transformResult` is async, so ciphertext is
34
+ decrypted on the way out and application code is unchanged. Writes are not.
35
+ `transformQuery` is synchronous — it returns a `RootOperationNode`, not a
36
+ promise — and WebCrypto is async, so the plugin cannot encrypt on the way in.
37
+ What it does instead is **refuse**: plaintext heading for a `wrapped` or
38
+ `sealed` column throws, and values are produced by
39
+ `ClassificationCrypto.encryptColumn()`. The security property is intact — a
40
+ forgotten call site is a loud error rather than a silent plaintext row — but
41
+ "transparent" describes only half of it.
42
+
43
+ Two alternatives were rejected to get there. Node's synchronous `crypto` would
44
+ fork crypto away from core and break every non-Node runtime. Proxying the query
45
+ builder is the kind of workaround the repo bans. A **`Driver` wrapper**
46
+ encrypting compiled-query parameters *would* deliver full write transparency and
47
+ reuses the manifest walk and resolver verbatim, so it stays open — but it needs
48
+ a parameter-index-to-column mapping recovered after compilation, and that
49
+ mapping goes fragile exactly where inserts get interesting (multi-row,
50
+ `onConflict().doUpdateSet()`). A mapping that is subtly wrong encrypts the wrong
51
+ column, which is worse than the problem it solves. Not worth it until the write
52
+ path is the thing actually hurting.
53
+
54
+ That plugin lives here, in `@pikku/kysely`, and not in a SQLite-specific
55
+ package. Nothing about wrapping a column value is dialect-specific, and the same
56
+ plugin is wanted against Postgres — fabric has the same classified columns and
57
+ the same reason to protect them. Putting it beside the dialect would mean
58
+ writing it twice.
59
+
60
+ The `hashed` form must never be encrypted, and the existing `ColumnForm` JSDoc
61
+ already says why: a token hash *is* the lookup key, so encrypting it makes the
62
+ lookup impossible.
63
+
64
+ The cost was measured on an M-series Mac rather than estimated: envelope
65
+ encryption runs about **38µs per value** to write and **33µs** to read, which
66
+ over 500 rows batched with `Promise.all` is roughly **9ms**. Key derivation is
67
+ the expensive step at **~48ms**, and it happens once, at unlock.
68
+
69
+ **What this rules out:** SQLCipher or whole-file encryption, encrypting every
70
+ column, and any design where an application function calls the crypto helpers
71
+ directly.
@@ -0,0 +1,15 @@
1
+ ---
2
+ type: overview
3
+ title: Decisions
4
+ description: How encrypted columns are wrapped, and which key wraps them
5
+ ---
6
+
7
+ # Decisions
8
+
9
+ <!-- pikku:knowledge-index -->
10
+
11
+ - [Classification-driven encryption happens in a Kysely plugin](classification-driven-encryption-happens-in-a-plugin.md) — The existing column classification manifest drives a transparent query plugin, rather than encrypting the whole database file or asking call sites to encrypt
12
+ - [KEKs are scoped by purpose as well as version](keks-are-scoped-by-purpose-as-well-as-version.md) — Rows carry a keyId, and the plugin resolves KEKs through a seam rather than holding one; v1 ships a single key, but scoping never becomes a migration
13
+ - [The plugin is given a resolver, never a key](the-plugin-is-given-a-resolver-never-a-key.md) — Assembly order for classified columns — a plain Kysely handle, then a plugin that asks a `KEKResolver` per operation rather than being handed a key it holds for the life of the process
14
+
15
+ <!-- /pikku:knowledge-index -->
@@ -0,0 +1,45 @@
1
+ ---
2
+ type: decision
3
+ title: KEKs are scoped by purpose as well as version
4
+ description: Rows carry a keyId, and the plugin resolves KEKs through a seam rather than holding one; v1 ships a single key, but scoping never becomes a migration
5
+ tags: [security, encryption, kysely, keys]
6
+ ---
7
+
8
+ # KEKs are scoped by purpose as well as version
9
+
10
+ `KyselySecretService` already versions its key-encrypting key: each row stores a
11
+ `keyVersion` beside its `ciphertext` and `wrappedDek`, per-version salts live in
12
+ their own table, and `rotateKEK` walks rows through `envelopeRewrap`. Version is
13
+ the *when* of a key. It is not the *what* — one KEK per version means every
14
+ encrypted column in the database is protected by the same secret, so unlocking
15
+ anything unlocks everything.
16
+
17
+ That is acceptable for the first release and wrong as a permanent shape. A
18
+ desktop app has one user and one passphrase; a multi-tenant deployment does not,
19
+ and "the passphrase that opens my notes also opens my credentials" is a
20
+ distinction people expect to be able to draw. So the design keeps scoping
21
+ possible without paying for it now.
22
+
23
+ Two things carry that. Encrypted rows store a **`keyId`** alongside `keyVersion`,
24
+ so a row records which key protects it rather than inheriting the answer from
25
+ context — retrofitting that column later would mean rewriting every encrypted
26
+ row in every deployment. And the plugin does not hold a KEK; it calls a
27
+ **resolver** that maps a `keyId` to one. In v1 the resolver returns the same key
28
+ for every id. Scoping by tenant, or by purpose, replaces the resolver.
29
+
30
+ Separate purposes derive from the same key material rather than requiring
31
+ separate passphrases. `expandKeyMaterial(keyMaterial, info, salt)` is the HKDF
32
+ helper already used for `REMOTE_SESSION_INFO`, and a distinct `info` string per
33
+ purpose yields independent keys from one unlock — which is what makes multiple
34
+ KEKs practical for a single-user app at all.
35
+
36
+ The envelope survives all of this unchanged. Core's decision that
37
+ [secrets use a per-secret DEK wrapped by a KEK](../../../../core/knowledge/decisions/security/core-secrets-use-a-per-secret-dek-wrapped-by-a-kek.md)
38
+ already rules out collapsing it, and scoping is the argument *for* the envelope
39
+ rather than against it: rescoping or rotating a key rewraps DEKs, touching a
40
+ small constant per row, while a design that encrypted values directly under the
41
+ KEK would have to decrypt and re-encrypt every value.
42
+
43
+ **What this rules out:** a single process-wide KEK with no row-level record of
44
+ which key applies, a separate passphrase per purpose, and deferring the `keyId`
45
+ column to a later migration.
@@ -0,0 +1,45 @@
1
+ ---
2
+ type: decision
3
+ title: The plugin is given a resolver, never a key
4
+ description: Assembly order for classified columns — a plain Kysely handle, then a plugin that asks a `KEKResolver` per operation rather than being handed a key it holds for the life of the process
5
+ tags: [security, encryption, kysely, classification]
6
+ ---
7
+
8
+ # The plugin is given a resolver, never a key
9
+
10
+ The pieces look circular the first time you assemble them: the plugin needs a
11
+ key, and whatever produces that key may itself need to read from the database.
12
+ It resolves because the plugin is never handed a key at all.
13
+
14
+ ```ts
15
+ const db = new Kysely<DB>({ dialect })
16
+
17
+ const crypto = new ClassificationCrypto({ resolveKEK })
18
+ const classified = db.withPlugin(
19
+ createClassificationPlugin({ manifest, crypto })
20
+ )
21
+ ```
22
+
23
+ **The plugin is given a resolver, never a key.** `resolveKEK` is asked _per
24
+ operation_. That is the whole reason a key can arrive after everything around it
25
+ has been constructed — neither the services, the Kysely instance nor the plugin
26
+ are rebuilt when it does — and it is why a key source that later becomes
27
+ unavailable makes the next classified query fail rather than use a stale key.
28
+
29
+ **A resolver that reads from the database reads through the plain handle**, not
30
+ the classified one, and whatever it reads must not itself be classified. A key
31
+ source that needed its own key to be read could never answer.
32
+
33
+ **A resolver may derive a KEK any way it likes**, as long as the same `keyId`
34
+ resolves to the same key: from a passphrase, from a KMS, from an environment
35
+ secret. `ClassificationCrypto` only requires the key and the version it should
36
+ stamp into the envelope. Deriving on every call is the mistake to avoid — key
37
+ stretching is the expensive step, so a resolver caches per `keyId`.
38
+
39
+ **What this rules out:** constructing the Kysely instance with the classification
40
+ plugin already attached before a key source exists, giving a database-backed
41
+ resolver the classified handle, and resolving a key once to cache it inside the
42
+ plugin.
43
+
44
+ Related: [[classification-driven-encryption-happens-in-a-plugin]],
45
+ [[keks-are-scoped-by-purpose-as-well-as-version]].
@@ -0,0 +1,18 @@
1
+ ---
2
+ type: overview
3
+ title: Knowledge
4
+ description: Why encryption in this package is a query-layer concern rather than a call-site one
5
+ ---
6
+
7
+ # Knowledge
8
+
9
+ Encrypting data at rest inside a database that still has to answer SQL queries
10
+ is a set of trade-offs, not a feature toggle. What gets encrypted, who does the
11
+ encrypting, and which key protects which rows are three separate questions, and
12
+ answering them independently is what keeps the result usable.
13
+
14
+ These notes record how this package answers them.
15
+
16
+ <!-- pikku:knowledge-index -->
17
+ - [decisions](decisions/index.md) — a rule that was chosen, and what it rules out
18
+ <!-- /pikku:knowledge-index -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/kysely",
3
- "version": "0.13.22",
3
+ "version": "0.13.24",
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.93"
24
+ "@pikku/core": "^0.12.104"
25
25
  },
26
26
  "dependencies": {
27
27
  "kysely": "^0.29.5"
@@ -0,0 +1,143 @@
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
+ })
@@ -0,0 +1,163 @@
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
+ }