Skip to content

Commit 79a1c27

Browse files
authored
First cut at encryption rules (#74)
* First cut at encryption rules * Add tests * Clean up package.json * Clean up package.json * Add kms clients * Minor fix
1 parent 2adc75e commit 79a1c27

30 files changed

+4503
-1062
lines changed

package-lock.json

Lines changed: 3453 additions & 944 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,11 @@
3232
},
3333
"license": "MIT",
3434
"devDependencies": {
35+
"@bufbuild/buf": "^1.37.0",
36+
"@bufbuild/protoc-gen-es": "^2.0.0",
3537
"@eslint/js": "^9.9.0",
3638
"@types/eslint__js": "^8.42.3",
37-
"@types/node": "^20.4.5",
39+
"@types/node": "^20.16.1",
3840
"bluebird": "^3.5.3",
3941
"eslint": "^8.57.0",
4042
"eslint-plugin-jest": "^28.6.0",
@@ -46,13 +48,16 @@
4648
"typescript-eslint": "^8.2.0"
4749
},
4850
"dependencies": {
49-
"@bufbuild/buf": "^1.37.0",
51+
"@aws-sdk/client-kms": "^3.637.0",
52+
"@azure/identity": "^4.4.1",
53+
"@azure/keyvault-keys": "^4.8.0",
5054
"@bufbuild/protobuf": "^2.0.0",
51-
"@bufbuild/protoc-gen-es": "^2.0.0",
5255
"@criteria/json-schema": "^0.10.0",
5356
"@criteria/json-schema-validation": "^0.10.0",
57+
"@google-cloud/kms": "^4.5.0",
5458
"@hackbg/miscreant-esm": "^0.3.2-patch.3",
5559
"@mapbox/node-pre-gyp": "^1.0.11",
60+
"@smithy/types": "^3.3.0",
5661
"@types/validator": "^13.12.0",
5762
"ajv": "^8.17.1",
5863
"async-mutex": "^0.5.0",
@@ -61,8 +66,8 @@
6166
"bindings": "^1.3.1",
6267
"json-stringify-deterministic": "^1.0.12",
6368
"lru-cache": "^11.0.0",
64-
"miscreant": "^0.3.2",
6569
"nan": "^2.17.0",
70+
"node-vault": "^0.10.2",
6671
"ts-jest": "^29.2.4",
6772
"validator": "^13.12.0"
6873
},

schemaregistry/dekregistry/dekregistry-client.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { LRUCache } from 'lru-cache';
22
import { Mutex } from 'async-mutex';
33
import { ClientConfig, RestService } from '../rest-service';
44
import stringify from 'json-stringify-deterministic';
5+
import {MockDekRegistryClient} from "./mock-dekregistry-client";
56

67
/*
78
* Confluent-Schema-Registry-TypeScript - Node.js wrapper for Confluent Schema Registry
@@ -76,6 +77,14 @@ class DekRegistryClient implements Client {
7677
this.dekMutex = new Mutex();
7778
}
7879

80+
static newClient(config: ClientConfig): Client {
81+
let url = config.baseURLs[0]
82+
if (url.startsWith("mock://")) {
83+
return new MockDekRegistryClient()
84+
}
85+
return new DekRegistryClient(config)
86+
}
87+
7988
static getEncryptedKeyMaterialBytes(dek: Dek): Buffer | null {
8089
if (!dek.encryptedKeyMaterial) {
8190
return null;

schemaregistry/dekregistry/mock-dekregistry-client.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Client, Dek, Kek } from "./dekregistry-client";
22
import { MOCK_TS } from "./constants";
33
import stringify from "json-stringify-deterministic";
4+
import {RestError} from "../rest-error";
45

56
class MockDekRegistryClient implements Client {
67
private kekCache: Map<string, Kek>;
@@ -39,7 +40,7 @@ class MockDekRegistryClient implements Client {
3940
return cachedKek;
4041
}
4142

42-
throw new Error(`Kek not found: ${name}`);
43+
throw new RestError(`Kek not found: ${name}`, 404, 40400);
4344
}
4445

4546
async registerDek(kekName: string, subject: string, algorithm: string,
@@ -75,18 +76,18 @@ class MockDekRegistryClient implements Client {
7576
}
7677
}
7778
if (latestVersion === 0) {
78-
throw new Error(`Dek not found: ${subject}`);
79+
throw new RestError(`Dek not found: ${subject}`, 404, 40400);
7980
}
8081
version = latestVersion;
8182
}
8283

83-
const cacheKey = stringify({ kekName, subject, version, algorithm, deleted });
84+
const cacheKey = stringify({ kekName, subject, version, algorithm, deleted: false });
8485
const cachedDek = this.dekCache.get(cacheKey);
8586
if (cachedDek) {
8687
return cachedDek;
8788
}
8889

89-
throw new Error(`Dek not found: ${subject}`);
90+
throw new RestError(`Dek not found: ${subject}`, 404, 40400);
9091
}
9192

9293
async close() {

schemaregistry/mock-schemaregistry-client.ts

Lines changed: 29 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,15 @@
11

2-
import { Client, Compatibility, SchemaInfo, SchemaMetadata, ServerConfig } from './schemaregistry-client';
2+
import {
3+
Client,
4+
Compatibility,
5+
minimize,
6+
SchemaInfo,
7+
SchemaMetadata,
8+
ServerConfig
9+
} from './schemaregistry-client';
310
import stringify from "json-stringify-deterministic";
411
import {ClientConfig} from "./rest-service";
12+
import {RestError} from "./rest-error";
513

614
interface VersionCacheEntry {
715
version: number;
@@ -57,13 +65,13 @@ class MockClient implements Client {
5765
async register(subject: string, schema: SchemaInfo, normalize: boolean = false): Promise<number> {
5866
const metadata = await this.registerFullResponse(subject, schema, normalize);
5967
if (!metadata) {
60-
throw new Error("Failed to register schema");
68+
throw new RestError("Failed to register schema", 422, 42200);
6169
}
6270
return metadata.id;
6371
}
6472

6573
async registerFullResponse(subject: string, schema: SchemaInfo, normalize: boolean = false): Promise<SchemaMetadata> {
66-
const cacheKey = stringify({ subject, schema });
74+
const cacheKey = stringify({ subject, schema: minimize(schema) });
6775

6876
const cacheEntry = this.infoToSchemaCache.get(cacheKey);
6977
if (cacheEntry && !cacheEntry.softDeleted) {
@@ -72,7 +80,7 @@ class MockClient implements Client {
7280

7381
const id = await this.getIDFromRegistry(subject, schema);
7482
if (id === -1) {
75-
throw new Error("Failed to retrieve schema ID from registry");
83+
throw new RestError("Failed to retrieve schema ID from registry", 422, 42200);
7684
}
7785

7886
const metadata: SchemaMetadata = { ...schema, id };
@@ -112,7 +120,7 @@ class MockClient implements Client {
112120
newVersion = versions[versions.length - 1] + 1;
113121
}
114122

115-
const cacheKey = stringify({ subject, schema: schema });
123+
const cacheKey = stringify({ subject, schema: minimize(schema) });
116124
this.schemaToVersionCache.set(cacheKey, { version: newVersion, softDeleted: false });
117125
}
118126

@@ -121,24 +129,24 @@ class MockClient implements Client {
121129
const cacheEntry = this.idToSchemaCache.get(cacheKey);
122130

123131
if (!cacheEntry || cacheEntry.softDeleted) {
124-
throw new Error("Schema not found");
132+
throw new RestError("Schema not found", 404, 40400);
125133
}
126134
return cacheEntry.info;
127135
}
128136

129137
async getId(subject: string, schema: SchemaInfo): Promise<number> {
130-
const cacheKey = stringify({ subject, schema });
138+
const cacheKey = stringify({ subject, schema: minimize(schema) });
131139
const cacheEntry = this.infoToSchemaCache.get(cacheKey);
132140
if (!cacheEntry || cacheEntry.softDeleted) {
133-
throw new Error("Schema not found");
141+
throw new RestError("Schema not found", 404, 40400);
134142
}
135143
return cacheEntry.metadata.id;
136144
}
137145

138146
async getLatestSchemaMetadata(subject: string): Promise<SchemaMetadata> {
139147
const version = await this.latestVersion(subject);
140148
if (version === -1) {
141-
throw new Error("No versions found for subject");
149+
throw new RestError("No versions found for subject", 404, 40400);
142150
}
143151

144152
return this.getSchemaMetadata(subject, version);
@@ -154,7 +162,7 @@ class MockClient implements Client {
154162
}
155163

156164
if (!json) {
157-
throw new Error("Schema not found");
165+
throw new RestError("Schema not found", 404, 40400);
158166
}
159167

160168
let id: number = -1;
@@ -165,15 +173,15 @@ class MockClient implements Client {
165173
}
166174
}
167175
if (id === -1) {
168-
throw new Error("Schema not found");
176+
throw new RestError("Schema not found", 404, 40400);
169177
}
170178

171179

172180
return {
173181
id,
174182
version,
175183
subject,
176-
schema: json.schema.schema
184+
...json.schema,
177185
};
178186
}
179187

@@ -198,7 +206,7 @@ class MockClient implements Client {
198206
}
199207

200208
if (results.length === 0) {
201-
throw new Error("Schema not found");
209+
throw new RestError("Schema not found", 404, 40400);
202210
}
203211

204212
let latest: SchemaMetadata = results[0];
@@ -225,7 +233,7 @@ class MockClient implements Client {
225233
const results = await this.allVersions(subject);
226234

227235
if (results.length === 0) {
228-
throw new Error("No versions found for subject");
236+
throw new RestError("No versions found for subject", 404, 40400);
229237
}
230238
return results;
231239
}
@@ -275,11 +283,11 @@ class MockClient implements Client {
275283
}
276284

277285
async getVersion(subject: string, schema: SchemaInfo, normalize: boolean = false): Promise<number> {
278-
const cacheKey = stringify({ subject, schema });
286+
const cacheKey = stringify({ subject, schema: minimize(schema) });
279287
const cacheEntry = this.schemaToVersionCache.get(cacheKey);
280288

281289
if (!cacheEntry || cacheEntry.softDeleted) {
282-
throw new Error("Schema not found");
290+
throw new RestError("Schema not found", 404, 40400);
283291
}
284292

285293
return cacheEntry.version;
@@ -333,7 +341,7 @@ class MockClient implements Client {
333341
if (parsedKey.subject === subject && value.version === version) {
334342
await this.deleteVersion(key, version, permanent);
335343

336-
const cacheKeySchema = stringify({ subject, schema: parsedKey.schema });
344+
const cacheKeySchema = stringify({ subject, schema: minimize(parsedKey.schema) });
337345
const cacheEntry = this.infoToSchemaCache.get(cacheKeySchema);
338346
if (cacheEntry) {
339347
await this.deleteMetadata(cacheKeySchema, cacheEntry.metadata, permanent);
@@ -363,7 +371,7 @@ class MockClient implements Client {
363371
async getCompatibility(subject: string): Promise<Compatibility> {
364372
const cacheEntry = this.configCache.get(subject);
365373
if (!cacheEntry) {
366-
throw new Error("Subject not found");
374+
throw new RestError("Subject not found", 404, 40400);
367375
}
368376
return cacheEntry.compatibilityLevel as Compatibility;
369377
}
@@ -376,7 +384,7 @@ class MockClient implements Client {
376384
async getDefaultCompatibility(): Promise<Compatibility> {
377385
const cacheEntry = this.configCache.get(noSubject);
378386
if (!cacheEntry) {
379-
throw new Error("Default compatibility not found");
387+
throw new RestError("Default compatibility not found", 404, 40400);
380388
}
381389
return cacheEntry.compatibilityLevel as Compatibility;
382390
}
@@ -389,7 +397,7 @@ class MockClient implements Client {
389397
async getConfig(subject: string): Promise<ServerConfig> {
390398
const cacheEntry = this.configCache.get(subject);
391399
if (!cacheEntry) {
392-
throw new Error("Subject not found");
400+
throw new RestError("Subject not found", 404, 40400);
393401
}
394402
return cacheEntry;
395403
}
@@ -402,7 +410,7 @@ class MockClient implements Client {
402410
async getDefaultConfig(): Promise<ServerConfig> {
403411
const cacheEntry = this.configCache.get(noSubject);
404412
if (!cacheEntry) {
405-
throw new Error("Default config not found");
413+
throw new RestError("Default config not found", 404, 40400);
406414
}
407415
return cacheEntry;
408416
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import {KmsClient} from "../kms-registry";
2+
import {AwsKmsDriver} from "./aws-driver";
3+
import {
4+
DecryptCommand,
5+
EncryptCommand,
6+
KMSClient
7+
} from '@aws-sdk/client-kms'
8+
import {AwsCredentialIdentity} from "@smithy/types";
9+
10+
export class AwsKmsClient implements KmsClient {
11+
12+
private kmsClient: KMSClient
13+
private keyId: string
14+
15+
constructor(keyUri: string, creds?: AwsCredentialIdentity) {
16+
if (!keyUri.startsWith(AwsKmsDriver.PREFIX)) {
17+
throw new Error(`key uri must start with ${AwsKmsDriver.PREFIX}`)
18+
}
19+
this.keyId = keyUri.substring(AwsKmsDriver.PREFIX.length)
20+
const tokens = this.keyId.split(':')
21+
if (tokens.length < 4) {
22+
throw new Error(`invalid key uri ${this.keyId}`)
23+
}
24+
const regionName = tokens[3]
25+
this.kmsClient = new KMSClient({
26+
region: regionName,
27+
...creds && {credentials: creds}
28+
})
29+
}
30+
31+
supported(keyUri: string): boolean {
32+
return keyUri.startsWith(AwsKmsDriver.PREFIX)
33+
}
34+
35+
async encrypt(plaintext: Buffer): Promise<Buffer> {
36+
const encryptCommand = new EncryptCommand({KeyId: this.keyId, Plaintext: plaintext});
37+
const data = await this.kmsClient.send(encryptCommand)
38+
return Buffer.from(data.CiphertextBlob!);
39+
}
40+
41+
async decrypt(ciphertext: Buffer): Promise<Buffer> {
42+
const decryptCommand = new DecryptCommand({KeyId: this.keyId, CiphertextBlob: ciphertext});
43+
const data = await this.kmsClient.send(decryptCommand);
44+
return Buffer.from(data.Plaintext!)
45+
}
46+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import {KmsClient, KmsDriver, registerKmsDriver} from "../kms-registry";
2+
import {AwsKmsClient} from "./aws-client";
3+
import {AwsCredentialIdentity} from "@smithy/types";
4+
5+
export class AwsKmsDriver implements KmsDriver {
6+
7+
static PREFIX = 'aws-kms://'
8+
static ACCESS_KEY_ID = 'access.key.id'
9+
static SECRET_ACCESS_KEY = 'secret.access.key'
10+
11+
static register(): void {
12+
registerKmsDriver(new AwsKmsDriver())
13+
}
14+
15+
getKeyUrlPrefix(): string {
16+
return AwsKmsDriver.PREFIX
17+
}
18+
19+
newKmsClient(config: Map<string, string>, keyUrl?: string): KmsClient {
20+
const uriPrefix = keyUrl != null ? keyUrl : AwsKmsDriver.PREFIX
21+
const key = config.get(AwsKmsDriver.ACCESS_KEY_ID)
22+
const secret = config.get(AwsKmsDriver.SECRET_ACCESS_KEY)
23+
let creds: AwsCredentialIdentity | undefined
24+
if (key != null && secret != null) {
25+
creds = {accessKeyId: key, secretAccessKey: secret}
26+
}
27+
return new AwsKmsClient(uriPrefix, creds)
28+
}
29+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import {KmsClient} from "../kms-registry";
2+
import {AzureKmsDriver} from "./azure-driver";
3+
import {TokenCredential} from "@azure/identity";
4+
import {CryptographyClient, EncryptionAlgorithm} from "@azure/keyvault-keys";
5+
6+
export class AzureKmsClient implements KmsClient {
7+
private static ALGORITHM: EncryptionAlgorithm = 'RSA-OAEP-256'
8+
9+
private kmsClient: CryptographyClient
10+
private keyId: string
11+
12+
constructor(keyUri: string, creds: TokenCredential) {
13+
if (!keyUri.startsWith(AzureKmsDriver.PREFIX)) {
14+
throw new Error(`key uri must start with ${AzureKmsDriver.PREFIX}`)
15+
}
16+
this.keyId = keyUri.substring(AzureKmsDriver.PREFIX.length)
17+
this.kmsClient = new CryptographyClient(this.keyId, creds)
18+
}
19+
20+
supported(keyUri: string): boolean {
21+
return keyUri.startsWith(AzureKmsDriver.PREFIX)
22+
}
23+
24+
async encrypt(plaintext: Buffer): Promise<Buffer> {
25+
const result = await this.kmsClient.encrypt(AzureKmsClient.ALGORITHM, plaintext)
26+
return Buffer.from(result.result)
27+
}
28+
29+
async decrypt(ciphertext: Buffer): Promise<Buffer> {
30+
const result = await this.kmsClient.decrypt(AzureKmsClient.ALGORITHM, ciphertext)
31+
return Buffer.from(result.result)
32+
}
33+
}

0 commit comments

Comments
 (0)