@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.
- package/CHANGELOG.md +271 -0
- package/dist/src/index.d.ts +4 -1
- package/dist/src/index.js +3 -1
- package/dist/src/kysely-analytics-service.d.ts +30 -0
- package/dist/src/kysely-analytics-service.js +80 -0
- package/dist/src/kysely-feature-flag-store.d.ts +37 -0
- package/dist/src/kysely-feature-flag-store.js +239 -0
- package/dist/src/kysely-scope-service.d.ts +1 -0
- package/dist/src/kysely-scope-service.js +29 -0
- package/dist/src/kysely-tables.d.ts +45 -0
- package/dist/src/schema/analytics.schema.d.ts +17 -0
- package/dist/src/schema/analytics.schema.js +48 -0
- package/dist/src/schema/flag.schema.d.ts +16 -0
- package/dist/src/schema/flag.schema.js +62 -0
- package/dist/src/schema/index.d.ts +2 -0
- package/dist/src/schema/index.js +6 -0
- package/package.json +11 -6
- package/run-tests.sh +9 -1
- package/bin/pikku-kysely-pure.ts +0 -59
- package/dist/src/kysely-lock-vault.d.ts +0 -34
- package/dist/src/kysely-lock-vault.js +0 -77
- package/dist/src/schema/data-lock.schema.d.ts +0 -15
- package/dist/src/schema/data-lock.schema.js +0 -27
- package/dist/tsconfig.tsbuildinfo +0 -1
- package/src/classification-crypto.test.ts +0 -143
- package/src/classification-crypto.ts +0 -163
- package/src/classification-plugin.pglite.test.ts +0 -226
- package/src/classification-plugin.test.ts +0 -265
- package/src/classification-plugin.ts +0 -249
- package/src/coercion-plugin.test.ts +0 -160
- package/src/coercion-plugin.ts +0 -187
- package/src/create-audited-kysely.test.ts +0 -560
- package/src/create-audited-kysely.ts +0 -254
- package/src/index.ts +0 -81
- package/src/kysely-agent-run-scores.ts +0 -47
- package/src/kysely-agent-run-service.ts +0 -244
- package/src/kysely-agent-run-state-service.ts +0 -200
- package/src/kysely-agent-storage-service.ts +0 -632
- package/src/kysely-audit-service.test.ts +0 -352
- package/src/kysely-audit-service.ts +0 -237
- package/src/kysely-channel-store.ts +0 -107
- package/src/kysely-credential-service.ts +0 -286
- package/src/kysely-deployment-service.ts +0 -163
- package/src/kysely-eventhub-store.ts +0 -53
- package/src/kysely-json.ts +0 -5
- package/src/kysely-scope-service.test.ts +0 -418
- package/src/kysely-scope-service.ts +0 -425
- package/src/kysely-secret-service.ts +0 -242
- package/src/kysely-services.test.ts +0 -405
- package/src/kysely-session-store.ts +0 -57
- package/src/kysely-tables.test.ts +0 -110
- package/src/kysely-tables.ts +0 -434
- package/src/kysely-virtual-user-run-store.test.ts +0 -369
- package/src/kysely-virtual-user-run-store.ts +0 -235
- package/src/kysely-virtual-user-schedule-store.test.ts +0 -187
- package/src/kysely-virtual-user-schedule-store.ts +0 -187
- package/src/kysely-webhook-service.test.ts +0 -153
- package/src/kysely-webhook-service.ts +0 -139
- package/src/kysely-workflow-mirror.test.ts +0 -259
- package/src/kysely-workflow-mirror.ts +0 -374
- package/src/kysely-workflow-run-service.ts +0 -305
- package/src/kysely-workflow-service.test.ts +0 -544
- package/src/kysely-workflow-service.ts +0 -820
- package/src/schema/agent.schema.ts +0 -162
- package/src/schema/audit.schema.ts +0 -57
- package/src/schema/channel.schema.ts +0 -38
- package/src/schema/credential.schema.ts +0 -58
- package/src/schema/deployment.schema.ts +0 -47
- package/src/schema/index.ts +0 -441
- package/src/schema/pikku-schema.types.ts +0 -169
- package/src/schema/schema.test.ts +0 -591
- package/src/schema/scope.schema.ts +0 -110
- package/src/schema/secret.schema.ts +0 -49
- package/src/schema/session.schema.ts +0 -20
- package/src/schema/virtual-user-schedule.schema.ts +0 -48
- package/src/schema/virtual-user.schema.ts +0 -106
- package/src/schema/webhook.schema.ts +0 -58
- package/src/schema/workflow.schema.ts +0 -150
- package/src/serialize-plugin.ts +0 -233
- package/tsconfig.json +0 -17
package/bin/pikku-kysely-pure.ts
DELETED
|
@@ -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
|
-
}
|