@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.
- package/CHANGELOG.md +27 -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/kysely-webhook-service.d.ts +1 -1
- package/dist/src/kysely-webhook-service.js +1 -1
- 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
- package/src/kysely-webhook-service.test.ts +29 -0
- package/src/kysely-webhook-service.ts +4 -2
|
@@ -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.
|
|
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.
|
|
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
|
+
}
|