@prairielearn/config 4.1.1 → 4.2.0
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 +12 -0
- package/README.md +40 -10
- package/dist/index.d.ts +3 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/sources/kms.d.ts +3 -0
- package/dist/sources/kms.d.ts.map +1 -0
- package/dist/sources/kms.js +100 -0
- package/dist/sources/kms.js.map +1 -0
- package/dist/sources/kms.test.d.ts +2 -0
- package/dist/sources/kms.test.d.ts.map +1 -0
- package/dist/sources/kms.test.js +235 -0
- package/dist/sources/kms.test.js.map +1 -0
- package/dist/types.d.ts +5 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +8 -7
- package/src/index.ts +3 -4
- package/src/sources/kms.test.ts +285 -0
- package/src/sources/kms.ts +139 -0
- package/src/types.ts +5 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @prairielearn/config
|
|
2
2
|
|
|
3
|
+
## 4.2.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 7a9cda5: Add a KMS config source that decrypts encrypted config value objects.
|
|
8
|
+
|
|
9
|
+
### Patch Changes
|
|
10
|
+
|
|
11
|
+
- b6e03e9: Upgrade dependencies
|
|
12
|
+
- Updated dependencies [b6e03e9]
|
|
13
|
+
- @prairielearn/aws-imds@3.0.3
|
|
14
|
+
|
|
3
15
|
## 4.1.1
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
# `@prairielearn/config`
|
|
2
2
|
|
|
3
|
-
Utilities to help load configuration from various sources including a JSON file and AWS
|
|
3
|
+
Utilities to help load configuration from various sources including a JSON file, AWS Secrets Manager, and AWS KMS encrypted values. Config is made type-safe through a [Zod](https://github.com/colinhacks/zod) schema.
|
|
4
4
|
|
|
5
5
|
This package _should not_ be depended upon by other packages directly. Instead, import it into your application, load the config, and then provide any necessary values to other packages.
|
|
6
6
|
|
|
7
7
|
## Usage
|
|
8
8
|
|
|
9
9
|
```ts
|
|
10
|
-
import { ConfigLoader,
|
|
10
|
+
import { ConfigLoader, makeFileConfigSource } from '@prairielearn/config';
|
|
11
11
|
import { z } from 'zod';
|
|
12
12
|
|
|
13
13
|
const ConfigSchema = z.object({
|
|
@@ -16,7 +16,7 @@ const ConfigSchema = z.object({
|
|
|
16
16
|
|
|
17
17
|
const configLoader = new ConfigLoader(ConfigSchema);
|
|
18
18
|
|
|
19
|
-
await configLoader.loadAndValidate([
|
|
19
|
+
await configLoader.loadAndValidate([makeFileConfigSource('config.json')]);
|
|
20
20
|
|
|
21
21
|
console.log(configLoader.config);
|
|
22
22
|
// { hello: "world" }
|
|
@@ -25,13 +25,13 @@ console.log(configLoader.config);
|
|
|
25
25
|
Typically, you'll want to have a `config.ts` file in your own project that encapsulates this. Then, you can import the config elsewhere in the project.
|
|
26
26
|
|
|
27
27
|
```ts
|
|
28
|
-
import { ConfigLoader,
|
|
28
|
+
import { ConfigLoader, makeFileConfigSource } from '@prairielearn/config';
|
|
29
29
|
import { z } from 'zod';
|
|
30
30
|
|
|
31
31
|
const configLoader = new ConfigLoader(z.any());
|
|
32
32
|
|
|
33
33
|
export async function loadAndValidate(path: string) {
|
|
34
|
-
await configLoader.loadAndValidate([
|
|
34
|
+
await configLoader.loadAndValidate([makeFileConfigSource(path)]);
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
export default configLoader.config;
|
|
@@ -39,12 +39,42 @@ export default configLoader.config;
|
|
|
39
39
|
|
|
40
40
|
### Loading config from AWS
|
|
41
41
|
|
|
42
|
-
If you're running in AWS, you can use `
|
|
42
|
+
If you're running in AWS, you can use `makeImdsConfigSource()`, `makeSecretsManagerConfigSource()`, and `makeKmsConfigSource()` to load config from IMDS, Secrets Manager, and AWS KMS, respectively:
|
|
43
43
|
|
|
44
|
-
- `
|
|
45
|
-
- `
|
|
44
|
+
- `makeImdsConfigSource()` will load `hostname`, `instanceId`, and `awsRegion`, which will be available if your config schema contains these values.
|
|
45
|
+
- `makeSecretsManagerConfigSource()` will look for a `ConfSecret` tag on the instance. If found, the value of that tag will be used as a Secrets Manager secret ID, and that secret's value will be parsed as JSON and merged into the config.
|
|
46
|
+
- `makeKmsConfigSource()` will recursively decrypt encrypted config value objects already loaded into the accumulated config. It is a transforming source, so place it after any source that may introduce encrypted values and before final validation.
|
|
46
47
|
|
|
47
|
-
|
|
48
|
+
```ts
|
|
49
|
+
await configLoader.loadAndValidate([
|
|
50
|
+
makeFileConfigSource('config.json'),
|
|
51
|
+
makeImdsConfigSource(),
|
|
52
|
+
makeSecretsManagerConfigSource('ConfSecret'),
|
|
53
|
+
makeKmsConfigSource(),
|
|
54
|
+
]);
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Note that `makeImdsConfigSource()` and `makeSecretsManagerConfigSource()` are no-ops by default. To activate them, you must do one of the following:
|
|
48
58
|
|
|
49
59
|
- Set `CONFIG_LOAD_FROM_AWS=1` in the process environment.
|
|
50
|
-
- Chain them after `
|
|
60
|
+
- Chain them after `makeFileConfigSource()`, and ensure that the config file contains `{"runningInEc2": true}`.
|
|
61
|
+
|
|
62
|
+
`makeKmsConfigSource()` only creates a KMS client when encrypted config values are present. It resolves its region from `awsRegion` in the accumulated config or from the AWS SDK's default region provider chain.
|
|
63
|
+
|
|
64
|
+
Encrypted config values use this JSON object shape:
|
|
65
|
+
|
|
66
|
+
```json
|
|
67
|
+
{
|
|
68
|
+
"__encrypted": "aws-kms-v1",
|
|
69
|
+
"ciphertext": "base64-encoded KMS CiphertextBlob",
|
|
70
|
+
"context": {
|
|
71
|
+
"environment": "us-prod"
|
|
72
|
+
},
|
|
73
|
+
"metadata": {
|
|
74
|
+
"key": "alias/service-config/us-prod",
|
|
75
|
+
"description": "prairietest postgresql password"
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
The required runtime fields are `__encrypted`, `ciphertext`, and `context`. `ciphertext` is base64-decoded and passed to KMS as the `CiphertextBlob`, and `context` is passed to KMS as the exact encryption context with all values as strings. `metadata` is optional review/debug information; the example above shows recommended fields, but runtime decryption ignores metadata instead of trusting, validating, or passing it to KMS. KMS infers the key from the ciphertext during decrypt. Decrypted plaintext must be valid UTF-8 and is returned as a string.
|
package/dist/index.d.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
type AbstractConfig
|
|
3
|
-
export
|
|
4
|
-
|
|
5
|
-
}
|
|
2
|
+
import type { AbstractConfig, ConfigSource } from './types.js';
|
|
3
|
+
export { makeKmsConfigSource } from './sources/kms.js';
|
|
4
|
+
export type { AbstractConfig, ConfigSource } from './types.js';
|
|
6
5
|
export declare function makeLiteralConfigSource(config: AbstractConfig): {
|
|
7
6
|
load: () => Promise<AbstractConfig>;
|
|
8
7
|
};
|
|
@@ -25,5 +24,4 @@ export declare class ConfigLoader<Schema extends z.ZodTypeAny> {
|
|
|
25
24
|
reset(): void;
|
|
26
25
|
get config(): z.TypeOf<Schema>;
|
|
27
26
|
}
|
|
28
|
-
export {};
|
|
29
27
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAIxB,KAAK,cAAc,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAIxB,OAAO,KAAK,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAE/D,OAAO,EAAE,mBAAmB,EAAE,MAAM,kBAAkB,CAAC;AACvD,YAAY,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAE/D,wBAAgB,uBAAuB,CAAC,MAAM,EAAE,cAAc;;EAI7D;AAED,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,YAAY,CAS/D;AAED;;;GAGG;AACH,KAAK,oBAAoB,CAAC,CAAC,IAAI;KAC5B,CAAC,IAAI,MAAM,CAAC,GAAG,MAAM,SAAS,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,GAAG,KAAK;CAChD,CAAC,MAAM,CAAC,CAAC,CAAC;AAEX,wBAAgB,mBAAmB,CAAC,MAAM,SAAS,CAAC,CAAC,UAAU,EAC7D,OAAO,EAAE,OAAO,CAAC,MAAM,CAAC,oBAAoB,CAAC,CAAC,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,EAAE,MAAM,CAAC,CAAC,GACtE,YAAY,CAed;AAED,wBAAgB,8BAA8B,CAAC,MAAM,EAAE,MAAM,GAAG,YAAY,CAoC3E;AAED,wBAAgB,oBAAoB,IAAI,YAAY,CAiBnD;AAED,qBAAa,YAAY,CAAC,MAAM,SAAS,CAAC,CAAC,UAAU;IACnD,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;IAChC,OAAO,CAAC,cAAc,CAAkB;IAExC,YAAY,MAAM,EAAE,MAAM,EAOzB;IAEK,eAAe,CAAC,OAAO,GAAE,YAAY,CAAC,GAAG,CAAC,EAAO,iBAWtD;IAED,KAAK,SAEJ;IAED,IAAI,MAAM,qBAET;CACF","sourcesContent":["import { DescribeTagsCommand, EC2Client } from '@aws-sdk/client-ec2';\nimport { GetSecretValueCommand, SecretsManagerClient } from '@aws-sdk/client-secrets-manager';\nimport { mergeWith } from 'es-toolkit';\nimport fs from 'fs-extra';\nimport { z } from 'zod';\n\nimport { fetchInstanceHostname, fetchInstanceIdentity } from '@prairielearn/aws-imds';\n\nimport type { AbstractConfig, ConfigSource } from './types.js';\n\nexport { makeKmsConfigSource } from './sources/kms.js';\nexport type { AbstractConfig, ConfigSource } from './types.js';\n\nexport function makeLiteralConfigSource(config: AbstractConfig) {\n return {\n load: async () => config,\n };\n}\n\nexport function makeFileConfigSource(path: string): ConfigSource {\n return {\n load: async () => {\n if (!(await fs.pathExists(path))) return {};\n\n const config = await fs.readJson(path);\n return z.record(z.string(), z.any()).parse(config);\n },\n };\n}\n\n/**\n * Extracts keys from T where string is assignable to the value type.\n * This ensures we only map environment variables to fields that accept strings.\n */\ntype StringAssignableKeys<T> = {\n [K in keyof T]: string extends T[K] ? K : never;\n}[keyof T];\n\nexport function makeEnvConfigSource<Schema extends z.ZodTypeAny>(\n mapping: Partial<Record<StringAssignableKeys<z.infer<Schema>>, string>>,\n): ConfigSource {\n return {\n load: async () => {\n const config: Record<string, string> = {};\n\n for (const [key, envVar] of Object.entries(mapping) as [string, string][]) {\n const value = process.env[envVar];\n if (value !== undefined) {\n config[key] = value;\n }\n }\n\n return config;\n },\n };\n}\n\nexport function makeSecretsManagerConfigSource(tagKey: string): ConfigSource {\n return {\n load: async (existingConfig) => {\n if (!existingConfig.runningInEc2 && !process.env.CONFIG_LOAD_FROM_AWS) {\n return {};\n }\n\n const identity = await fetchInstanceIdentity();\n\n // We disable the ESLint rule here because we don't care about sharing\n // configs between clients in this case. We only want to share configs\n // to avoid spamming the IMDS API when creating lots of clients, but\n // this client will only be used once, typically at application startup.\n // eslint-disable-next-line @prairielearn/aws-client-shared-config\n const ec2Client = new EC2Client({ region: identity.region });\n const tags = await ec2Client.send(\n new DescribeTagsCommand({\n Filters: [{ Name: 'resource-id', Values: [identity.instanceId] }],\n }),\n );\n\n const secretId = tags.Tags?.find((tag) => tag.Key === tagKey)?.Value;\n if (!secretId) return {};\n\n // As above, we don't care about sharing configs between clients.\n // eslint-disable-next-line @prairielearn/aws-client-shared-config\n const secretsManagerClient = new SecretsManagerClient({ region: identity.region });\n const secretValue = await secretsManagerClient.send(\n new GetSecretValueCommand({ SecretId: secretId }),\n );\n if (!secretValue.SecretString) return {};\n\n const config = JSON.parse(secretValue.SecretString);\n return z.record(z.string(), z.any()).parse(config);\n },\n };\n}\n\nexport function makeImdsConfigSource(): ConfigSource {\n return {\n load: async (existingConfig) => {\n if (!existingConfig.runningInEc2 && !process.env.CONFIG_LOAD_FROM_AWS) {\n return {};\n }\n\n const hostname = await fetchInstanceHostname();\n const identity = await fetchInstanceIdentity();\n\n return {\n hostname,\n instanceId: identity.instanceId,\n awsRegion: identity.region,\n };\n },\n };\n}\n\nexport class ConfigLoader<Schema extends z.ZodTypeAny> {\n private readonly schema: Schema;\n private resolvedConfig: z.infer<Schema>;\n\n constructor(schema: Schema) {\n this.schema = schema;\n\n // Get the default values from the schema. This ensures that all values\n // have defaults, and also allows us to override nested defaults with\n // `_.merge()` in `loadAndValidate()`.\n this.resolvedConfig = schema.parse({});\n }\n\n async loadAndValidate(sources: ConfigSource<any>[] = []) {\n let config = this.schema.parse({});\n // If the config setting is an array, override instead of merge\n const mergeRule = (_obj: any, src: any) => (Array.isArray(src) ? src : undefined);\n\n for (const source of sources) {\n config = mergeWith(config, await source.load(config), mergeRule);\n }\n\n const parsedConfig = this.schema.parse(config);\n mergeWith(this.resolvedConfig, parsedConfig, mergeRule);\n }\n\n reset() {\n this.resolvedConfig = this.schema.parse({});\n }\n\n get config() {\n return this.resolvedConfig;\n }\n}\n"]}
|
package/dist/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import { mergeWith } from 'es-toolkit';
|
|
|
4
4
|
import fs from 'fs-extra';
|
|
5
5
|
import { z } from 'zod';
|
|
6
6
|
import { fetchInstanceHostname, fetchInstanceIdentity } from '@prairielearn/aws-imds';
|
|
7
|
+
export { makeKmsConfigSource } from './sources/kms.js';
|
|
7
8
|
export function makeLiteralConfigSource(config) {
|
|
8
9
|
return {
|
|
9
10
|
load: async () => config,
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,mBAAmB,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAC;AACrE,OAAO,EAAE,qBAAqB,EAAE,oBAAoB,EAAE,MAAM,iCAAiC,CAAC;AAC9F,OAAO,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AACvC,OAAO,EAAE,MAAM,UAAU,CAAC;AAC1B,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,EAAE,qBAAqB,EAAE,qBAAqB,EAAE,MAAM,wBAAwB,CAAC;
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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","sourcesContent":["import { DescribeTagsCommand, EC2Client } from '@aws-sdk/client-ec2';\nimport { GetSecretValueCommand, SecretsManagerClient } from '@aws-sdk/client-secrets-manager';\nimport { mergeWith } from 'es-toolkit';\nimport fs from 'fs-extra';\nimport { z } from 'zod';\n\nimport { fetchInstanceHostname, fetchInstanceIdentity } from '@prairielearn/aws-imds';\n\nimport type { AbstractConfig, ConfigSource } from './types.js';\n\nexport { makeKmsConfigSource } from './sources/kms.js';\nexport type { AbstractConfig, ConfigSource } from './types.js';\n\nexport function makeLiteralConfigSource(config: AbstractConfig) {\n return {\n load: async () => config,\n };\n}\n\nexport function makeFileConfigSource(path: string): ConfigSource {\n return {\n load: async () => {\n if (!(await fs.pathExists(path))) return {};\n\n const config = await fs.readJson(path);\n return z.record(z.string(), z.any()).parse(config);\n },\n };\n}\n\n/**\n * Extracts keys from T where string is assignable to the value type.\n * This ensures we only map environment variables to fields that accept strings.\n */\ntype StringAssignableKeys<T> = {\n [K in keyof T]: string extends T[K] ? K : never;\n}[keyof T];\n\nexport function makeEnvConfigSource<Schema extends z.ZodTypeAny>(\n mapping: Partial<Record<StringAssignableKeys<z.infer<Schema>>, string>>,\n): ConfigSource {\n return {\n load: async () => {\n const config: Record<string, string> = {};\n\n for (const [key, envVar] of Object.entries(mapping) as [string, string][]) {\n const value = process.env[envVar];\n if (value !== undefined) {\n config[key] = value;\n }\n }\n\n return config;\n },\n };\n}\n\nexport function makeSecretsManagerConfigSource(tagKey: string): ConfigSource {\n return {\n load: async (existingConfig) => {\n if (!existingConfig.runningInEc2 && !process.env.CONFIG_LOAD_FROM_AWS) {\n return {};\n }\n\n const identity = await fetchInstanceIdentity();\n\n // We disable the ESLint rule here because we don't care about sharing\n // configs between clients in this case. We only want to share configs\n // to avoid spamming the IMDS API when creating lots of clients, but\n // this client will only be used once, typically at application startup.\n // eslint-disable-next-line @prairielearn/aws-client-shared-config\n const ec2Client = new EC2Client({ region: identity.region });\n const tags = await ec2Client.send(\n new DescribeTagsCommand({\n Filters: [{ Name: 'resource-id', Values: [identity.instanceId] }],\n }),\n );\n\n const secretId = tags.Tags?.find((tag) => tag.Key === tagKey)?.Value;\n if (!secretId) return {};\n\n // As above, we don't care about sharing configs between clients.\n // eslint-disable-next-line @prairielearn/aws-client-shared-config\n const secretsManagerClient = new SecretsManagerClient({ region: identity.region });\n const secretValue = await secretsManagerClient.send(\n new GetSecretValueCommand({ SecretId: secretId }),\n );\n if (!secretValue.SecretString) return {};\n\n const config = JSON.parse(secretValue.SecretString);\n return z.record(z.string(), z.any()).parse(config);\n },\n };\n}\n\nexport function makeImdsConfigSource(): ConfigSource {\n return {\n load: async (existingConfig) => {\n if (!existingConfig.runningInEc2 && !process.env.CONFIG_LOAD_FROM_AWS) {\n return {};\n }\n\n const hostname = await fetchInstanceHostname();\n const identity = await fetchInstanceIdentity();\n\n return {\n hostname,\n instanceId: identity.instanceId,\n awsRegion: identity.region,\n };\n },\n };\n}\n\nexport class ConfigLoader<Schema extends z.ZodTypeAny> {\n private readonly schema: Schema;\n private resolvedConfig: z.infer<Schema>;\n\n constructor(schema: Schema) {\n this.schema = schema;\n\n // Get the default values from the schema. This ensures that all values\n // have defaults, and also allows us to override nested defaults with\n // `_.merge()` in `loadAndValidate()`.\n this.resolvedConfig = schema.parse({});\n }\n\n async loadAndValidate(sources: ConfigSource<any>[] = []) {\n let config = this.schema.parse({});\n // If the config setting is an array, override instead of merge\n const mergeRule = (_obj: any, src: any) => (Array.isArray(src) ? src : undefined);\n\n for (const source of sources) {\n config = mergeWith(config, await source.load(config), mergeRule);\n }\n\n const parsedConfig = this.schema.parse(config);\n mergeWith(this.resolvedConfig, parsedConfig, mergeRule);\n }\n\n reset() {\n this.resolvedConfig = this.schema.parse({});\n }\n\n get config() {\n return this.resolvedConfig;\n }\n}\n"]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"kms.d.ts","sourceRoot":"","sources":["../../src/sources/kms.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AA6GhD,wBAAgB,mBAAmB,IAAI,YAAY,CA0BlD","sourcesContent":["import { DecryptCommand, type DecryptCommandOutput, KMSClient } from '@aws-sdk/client-kms';\nimport { z } from 'zod';\n\nimport type { ConfigSource } from '../types.js';\n\nconst EncryptedValueSchema = z.object({\n __encrypted: z.literal('aws-kms-v1'),\n ciphertext: z.string(),\n context: z.record(z.string(), z.string()),\n});\n\nfunction isPlainObject(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value);\n}\n\nfunction isEncryptedValue(value: unknown): boolean {\n return isPlainObject(value) && Object.hasOwn(value, '__encrypted');\n}\n\nfunction formatConfigPath(path: (string | number)[]): string {\n return path.length === 0\n ? '<root>'\n : path\n .map((part, index) =>\n typeof part === 'number' ? `[${part}]` : index === 0 ? part : `.${part}`,\n )\n .join('');\n}\n\nfunction formatZodIssues(error: z.ZodError): string {\n return error.issues\n .map((issue) => `${issue.path.join('.') || '<value>'}: ${issue.message}`)\n .join('; ');\n}\n\nfunction parseEncryptedValue(value: unknown, path: (string | number)[]) {\n const result = EncryptedValueSchema.safeParse(value);\n if (!result.success) {\n throw new Error(\n `Malformed encrypted config value at ${formatConfigPath(path)}: ${formatZodIssues(result.error)}`,\n );\n }\n\n return result.data;\n}\n\nasync function decryptEncryptedValue(\n value: unknown,\n path: (string | number)[],\n getKmsClient: () => KMSClient,\n): Promise<string> {\n const encryptedValue = parseEncryptedValue(value, path);\n const ciphertextBlob = Buffer.from(encryptedValue.ciphertext, 'base64');\n const kmsClient = getKmsClient();\n\n let result: DecryptCommandOutput;\n try {\n result = await kmsClient.send(\n new DecryptCommand({\n CiphertextBlob: ciphertextBlob,\n EncryptionContext: encryptedValue.context,\n }),\n );\n } catch (error) {\n throw new Error(`KMS decrypt failed for encrypted config value at ${formatConfigPath(path)}`, {\n cause: error,\n });\n }\n\n if (!result.Plaintext) {\n throw new Error(\n `KMS decrypt result missing Plaintext for encrypted config value at ${formatConfigPath(path)}`,\n );\n }\n\n try {\n return new TextDecoder('utf-8', { fatal: true }).decode(result.Plaintext);\n } catch (error) {\n throw new Error(\n `KMS decrypt result Plaintext is not valid UTF-8 for encrypted config value at ${formatConfigPath(path)}`,\n { cause: error },\n );\n }\n}\n\nasync function decryptEncryptedValuesInPlace(\n value: unknown,\n path: (string | number)[],\n getKmsClient: () => KMSClient,\n): Promise<unknown> {\n if (isEncryptedValue(value)) {\n return await decryptEncryptedValue(value, path, getKmsClient);\n }\n\n if (Array.isArray(value)) {\n for (const [index, item] of value.entries()) {\n value[index] = await decryptEncryptedValuesInPlace(item, [...path, index], getKmsClient);\n }\n return value;\n }\n\n if (!isPlainObject(value)) {\n return value;\n }\n\n for (const [key, childValue] of Object.entries(value)) {\n value[key] = await decryptEncryptedValuesInPlace(childValue, [...path, key], getKmsClient);\n }\n\n return value;\n}\n\nexport function makeKmsConfigSource(): ConfigSource {\n return {\n load: async (existingConfig) => {\n let kmsClient: KMSClient | undefined;\n\n // The client is created lazily so this source remains a no-op when there\n // are no encrypted values. If a client is created, it's then reused for\n // all decrypts in this load.\n const getKmsClient = () => {\n if (!kmsClient) {\n const region =\n typeof existingConfig.awsRegion === 'string' ? existingConfig.awsRegion : undefined;\n\n // We don't care about sharing configs between clients here; this\n // client is only used once, typically at application startup.\n // eslint-disable-next-line @prairielearn/aws-client-shared-config\n kmsClient = new KMSClient({ region });\n }\n return kmsClient;\n };\n\n const resolvedConfig = structuredClone(existingConfig);\n await decryptEncryptedValuesInPlace(resolvedConfig, [], getKmsClient);\n return resolvedConfig as Partial<typeof existingConfig>;\n },\n };\n}\n"]}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { DecryptCommand, KMSClient } from '@aws-sdk/client-kms';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
const EncryptedValueSchema = z.object({
|
|
4
|
+
__encrypted: z.literal('aws-kms-v1'),
|
|
5
|
+
ciphertext: z.string(),
|
|
6
|
+
context: z.record(z.string(), z.string()),
|
|
7
|
+
});
|
|
8
|
+
function isPlainObject(value) {
|
|
9
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
10
|
+
}
|
|
11
|
+
function isEncryptedValue(value) {
|
|
12
|
+
return isPlainObject(value) && Object.hasOwn(value, '__encrypted');
|
|
13
|
+
}
|
|
14
|
+
function formatConfigPath(path) {
|
|
15
|
+
return path.length === 0
|
|
16
|
+
? '<root>'
|
|
17
|
+
: path
|
|
18
|
+
.map((part, index) => typeof part === 'number' ? `[${part}]` : index === 0 ? part : `.${part}`)
|
|
19
|
+
.join('');
|
|
20
|
+
}
|
|
21
|
+
function formatZodIssues(error) {
|
|
22
|
+
return error.issues
|
|
23
|
+
.map((issue) => `${issue.path.join('.') || '<value>'}: ${issue.message}`)
|
|
24
|
+
.join('; ');
|
|
25
|
+
}
|
|
26
|
+
function parseEncryptedValue(value, path) {
|
|
27
|
+
const result = EncryptedValueSchema.safeParse(value);
|
|
28
|
+
if (!result.success) {
|
|
29
|
+
throw new Error(`Malformed encrypted config value at ${formatConfigPath(path)}: ${formatZodIssues(result.error)}`);
|
|
30
|
+
}
|
|
31
|
+
return result.data;
|
|
32
|
+
}
|
|
33
|
+
async function decryptEncryptedValue(value, path, getKmsClient) {
|
|
34
|
+
const encryptedValue = parseEncryptedValue(value, path);
|
|
35
|
+
const ciphertextBlob = Buffer.from(encryptedValue.ciphertext, 'base64');
|
|
36
|
+
const kmsClient = getKmsClient();
|
|
37
|
+
let result;
|
|
38
|
+
try {
|
|
39
|
+
result = await kmsClient.send(new DecryptCommand({
|
|
40
|
+
CiphertextBlob: ciphertextBlob,
|
|
41
|
+
EncryptionContext: encryptedValue.context,
|
|
42
|
+
}));
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
throw new Error(`KMS decrypt failed for encrypted config value at ${formatConfigPath(path)}`, {
|
|
46
|
+
cause: error,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
if (!result.Plaintext) {
|
|
50
|
+
throw new Error(`KMS decrypt result missing Plaintext for encrypted config value at ${formatConfigPath(path)}`);
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(result.Plaintext);
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
throw new Error(`KMS decrypt result Plaintext is not valid UTF-8 for encrypted config value at ${formatConfigPath(path)}`, { cause: error });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
async function decryptEncryptedValuesInPlace(value, path, getKmsClient) {
|
|
60
|
+
if (isEncryptedValue(value)) {
|
|
61
|
+
return await decryptEncryptedValue(value, path, getKmsClient);
|
|
62
|
+
}
|
|
63
|
+
if (Array.isArray(value)) {
|
|
64
|
+
for (const [index, item] of value.entries()) {
|
|
65
|
+
value[index] = await decryptEncryptedValuesInPlace(item, [...path, index], getKmsClient);
|
|
66
|
+
}
|
|
67
|
+
return value;
|
|
68
|
+
}
|
|
69
|
+
if (!isPlainObject(value)) {
|
|
70
|
+
return value;
|
|
71
|
+
}
|
|
72
|
+
for (const [key, childValue] of Object.entries(value)) {
|
|
73
|
+
value[key] = await decryptEncryptedValuesInPlace(childValue, [...path, key], getKmsClient);
|
|
74
|
+
}
|
|
75
|
+
return value;
|
|
76
|
+
}
|
|
77
|
+
export function makeKmsConfigSource() {
|
|
78
|
+
return {
|
|
79
|
+
load: async (existingConfig) => {
|
|
80
|
+
let kmsClient;
|
|
81
|
+
// The client is created lazily so this source remains a no-op when there
|
|
82
|
+
// are no encrypted values. If a client is created, it's then reused for
|
|
83
|
+
// all decrypts in this load.
|
|
84
|
+
const getKmsClient = () => {
|
|
85
|
+
if (!kmsClient) {
|
|
86
|
+
const region = typeof existingConfig.awsRegion === 'string' ? existingConfig.awsRegion : undefined;
|
|
87
|
+
// We don't care about sharing configs between clients here; this
|
|
88
|
+
// client is only used once, typically at application startup.
|
|
89
|
+
// eslint-disable-next-line @prairielearn/aws-client-shared-config
|
|
90
|
+
kmsClient = new KMSClient({ region });
|
|
91
|
+
}
|
|
92
|
+
return kmsClient;
|
|
93
|
+
};
|
|
94
|
+
const resolvedConfig = structuredClone(existingConfig);
|
|
95
|
+
await decryptEncryptedValuesInPlace(resolvedConfig, [], getKmsClient);
|
|
96
|
+
return resolvedConfig;
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
//# sourceMappingURL=kms.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"kms.js","sourceRoot":"","sources":["../../src/sources/kms.ts"],"names":[],"mappings":"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","sourcesContent":["import { DecryptCommand, type DecryptCommandOutput, KMSClient } from '@aws-sdk/client-kms';\nimport { z } from 'zod';\n\nimport type { ConfigSource } from '../types.js';\n\nconst EncryptedValueSchema = z.object({\n __encrypted: z.literal('aws-kms-v1'),\n ciphertext: z.string(),\n context: z.record(z.string(), z.string()),\n});\n\nfunction isPlainObject(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value);\n}\n\nfunction isEncryptedValue(value: unknown): boolean {\n return isPlainObject(value) && Object.hasOwn(value, '__encrypted');\n}\n\nfunction formatConfigPath(path: (string | number)[]): string {\n return path.length === 0\n ? '<root>'\n : path\n .map((part, index) =>\n typeof part === 'number' ? `[${part}]` : index === 0 ? part : `.${part}`,\n )\n .join('');\n}\n\nfunction formatZodIssues(error: z.ZodError): string {\n return error.issues\n .map((issue) => `${issue.path.join('.') || '<value>'}: ${issue.message}`)\n .join('; ');\n}\n\nfunction parseEncryptedValue(value: unknown, path: (string | number)[]) {\n const result = EncryptedValueSchema.safeParse(value);\n if (!result.success) {\n throw new Error(\n `Malformed encrypted config value at ${formatConfigPath(path)}: ${formatZodIssues(result.error)}`,\n );\n }\n\n return result.data;\n}\n\nasync function decryptEncryptedValue(\n value: unknown,\n path: (string | number)[],\n getKmsClient: () => KMSClient,\n): Promise<string> {\n const encryptedValue = parseEncryptedValue(value, path);\n const ciphertextBlob = Buffer.from(encryptedValue.ciphertext, 'base64');\n const kmsClient = getKmsClient();\n\n let result: DecryptCommandOutput;\n try {\n result = await kmsClient.send(\n new DecryptCommand({\n CiphertextBlob: ciphertextBlob,\n EncryptionContext: encryptedValue.context,\n }),\n );\n } catch (error) {\n throw new Error(`KMS decrypt failed for encrypted config value at ${formatConfigPath(path)}`, {\n cause: error,\n });\n }\n\n if (!result.Plaintext) {\n throw new Error(\n `KMS decrypt result missing Plaintext for encrypted config value at ${formatConfigPath(path)}`,\n );\n }\n\n try {\n return new TextDecoder('utf-8', { fatal: true }).decode(result.Plaintext);\n } catch (error) {\n throw new Error(\n `KMS decrypt result Plaintext is not valid UTF-8 for encrypted config value at ${formatConfigPath(path)}`,\n { cause: error },\n );\n }\n}\n\nasync function decryptEncryptedValuesInPlace(\n value: unknown,\n path: (string | number)[],\n getKmsClient: () => KMSClient,\n): Promise<unknown> {\n if (isEncryptedValue(value)) {\n return await decryptEncryptedValue(value, path, getKmsClient);\n }\n\n if (Array.isArray(value)) {\n for (const [index, item] of value.entries()) {\n value[index] = await decryptEncryptedValuesInPlace(item, [...path, index], getKmsClient);\n }\n return value;\n }\n\n if (!isPlainObject(value)) {\n return value;\n }\n\n for (const [key, childValue] of Object.entries(value)) {\n value[key] = await decryptEncryptedValuesInPlace(childValue, [...path, key], getKmsClient);\n }\n\n return value;\n}\n\nexport function makeKmsConfigSource(): ConfigSource {\n return {\n load: async (existingConfig) => {\n let kmsClient: KMSClient | undefined;\n\n // The client is created lazily so this source remains a no-op when there\n // are no encrypted values. If a client is created, it's then reused for\n // all decrypts in this load.\n const getKmsClient = () => {\n if (!kmsClient) {\n const region =\n typeof existingConfig.awsRegion === 'string' ? existingConfig.awsRegion : undefined;\n\n // We don't care about sharing configs between clients here; this\n // client is only used once, typically at application startup.\n // eslint-disable-next-line @prairielearn/aws-client-shared-config\n kmsClient = new KMSClient({ region });\n }\n return kmsClient;\n };\n\n const resolvedConfig = structuredClone(existingConfig);\n await decryptEncryptedValuesInPlace(resolvedConfig, [], getKmsClient);\n return resolvedConfig as Partial<typeof existingConfig>;\n },\n };\n}\n"]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"kms.test.d.ts","sourceRoot":"","sources":["../../src/sources/kms.test.ts"],"names":[],"mappings":"","sourcesContent":["import { DecryptCommand, KMSClient } from '@aws-sdk/client-kms';\nimport { assert, beforeEach, describe, expect, it, vi } from 'vitest';\nimport { z } from 'zod';\n\nimport { ConfigLoader, makeKmsConfigSource, makeLiteralConfigSource } from '../index.js';\n\nconst sendMock = vi.hoisted(() => vi.fn());\n\nvi.mock('@aws-sdk/client-kms', () => ({\n DecryptCommand: vi.fn(function DecryptCommand(input: unknown) {\n return { input };\n }),\n KMSClient: vi.fn(function KMSClient() {\n return { send: sendMock };\n }),\n}));\n\nfunction makeEncryptedValue(ciphertext = Buffer.from('ciphertext').toString('base64')) {\n return {\n __encrypted: 'aws-kms-v1',\n ciphertext,\n context: {\n environment: 'us-prod',\n },\n metadata: {\n key: 'alias/service-config/us-prod',\n description: 'prairietest postgresql password',\n },\n };\n}\n\ndescribe('makeKmsConfigSource', () => {\n beforeEach(() => {\n sendMock.mockReset();\n vi.mocked(DecryptCommand).mockClear();\n vi.mocked(KMSClient).mockClear();\n });\n\n it('returns an unchanged config and does not create a KMS client when there are no encrypted values', async () => {\n const source = makeKmsConfigSource();\n\n assert.deepEqual(await source.load({ secret: 'plaintext' }), { secret: 'plaintext' });\n expect(KMSClient).not.toHaveBeenCalled();\n });\n\n it('decrypts a top-level encrypted string', async () => {\n sendMock.mockResolvedValue({ Plaintext: new TextEncoder().encode('decrypted') });\n const schema = z.object({\n secret: z.string().default(''),\n });\n const loader = new ConfigLoader(schema);\n\n await loader.loadAndValidate([\n makeLiteralConfigSource({\n secret: makeEncryptedValue(),\n }),\n makeKmsConfigSource(),\n ]);\n\n assert.equal(loader.config.secret, 'decrypted');\n expect(DecryptCommand).toHaveBeenCalledWith({\n CiphertextBlob: Buffer.from('ciphertext'),\n EncryptionContext: {\n environment: 'us-prod',\n },\n });\n });\n\n it('ignores metadata when decrypting', async () => {\n sendMock.mockResolvedValue({ Plaintext: new TextEncoder().encode('decrypted') });\n\n await makeKmsConfigSource().load({\n secret: {\n ...makeEncryptedValue(),\n metadata: {\n key: 42,\n description: {\n text: 'metadata is not used by runtime decryption',\n },\n owner: ['course-staff'],\n },\n },\n });\n\n expect(DecryptCommand).toHaveBeenCalledWith({\n CiphertextBlob: Buffer.from('ciphertext'),\n EncryptionContext: {\n environment: 'us-prod',\n },\n });\n });\n\n it('passes encryption context through to KMS without requiring PrairieLearn-specific keys', async () => {\n sendMock.mockResolvedValue({ Plaintext: new TextEncoder().encode('decrypted') });\n\n await makeKmsConfigSource().load({\n secret: {\n ...makeEncryptedValue(),\n context: {\n deployment: 'self-hosted',\n purpose: 'config',\n },\n },\n });\n\n expect(DecryptCommand).toHaveBeenCalledWith({\n CiphertextBlob: Buffer.from('ciphertext'),\n EncryptionContext: {\n deployment: 'self-hosted',\n purpose: 'config',\n },\n });\n });\n\n it('uses awsRegion from existing config', async () => {\n sendMock.mockResolvedValue({ Plaintext: new TextEncoder().encode('decrypted') });\n\n await makeKmsConfigSource().load({\n awsRegion: 'us-west-2',\n secret: makeEncryptedValue(),\n });\n\n expect(KMSClient).toHaveBeenCalledWith({ region: 'us-west-2' });\n });\n\n it('returns the full transformed config when encrypted values exist', async () => {\n sendMock.mockResolvedValue({ Plaintext: new TextEncoder().encode('decrypted') });\n\n const result = await makeKmsConfigSource().load({\n database: {\n host: 'db.example.com',\n password: makeEncryptedValue(),\n },\n courseDirs: ['exampleCourse'],\n });\n\n assert.deepEqual(result, {\n database: {\n host: 'db.example.com',\n password: 'decrypted',\n },\n courseDirs: ['exampleCourse'],\n });\n });\n\n it('decrypts nested object and array values', async () => {\n sendMock\n .mockResolvedValueOnce({ Plaintext: new TextEncoder().encode('nested') })\n .mockResolvedValueOnce({ Plaintext: new TextEncoder().encode('array') });\n const schema = z.object({\n nested: z\n .object({\n secret: z.string().default(''),\n unchanged: z.string().default('kept'),\n })\n .default({ secret: '', unchanged: 'kept' }),\n values: z.array(z.union([z.string(), z.object({ secret: z.string() })])).default([]),\n });\n const loader = new ConfigLoader(schema);\n\n await loader.loadAndValidate([\n makeLiteralConfigSource({\n nested: {\n secret: makeEncryptedValue(),\n unchanged: 'kept',\n },\n values: ['first', { secret: makeEncryptedValue() }],\n }),\n makeKmsConfigSource(),\n ]);\n\n assert.deepEqual(loader.config, {\n nested: {\n secret: 'nested',\n unchanged: 'kept',\n },\n values: ['first', { secret: 'array' }],\n });\n expect(KMSClient).toHaveBeenCalledTimes(1);\n });\n\n it('does not mutate the source object when decrypting encrypted values', async () => {\n sendMock.mockResolvedValue({ Plaintext: new TextEncoder().encode('decrypted') });\n const encryptedValue = makeEncryptedValue();\n const sourceConfig = {\n secret: encryptedValue,\n };\n\n const result = await makeKmsConfigSource().load(sourceConfig);\n\n assert.deepEqual(encryptedValue, makeEncryptedValue());\n assert.deepEqual(sourceConfig, { secret: encryptedValue });\n assert.deepEqual(result, { secret: 'decrypted' });\n });\n\n it('throws on malformed encrypted values', async () => {\n await expect(\n makeKmsConfigSource().load({\n secret: {\n __encrypted: 'aws-kms-v1',\n ciphertext: Buffer.from('ciphertext').toString('base64'),\n },\n }),\n ).rejects.toThrow(/Malformed encrypted config value.*context/);\n\n await expect(\n makeKmsConfigSource().load({\n secret: {\n __encrypted: 'aws-kms-v1',\n context: {\n environment: 'us-prod',\n },\n },\n }),\n ).rejects.toThrow(/Malformed encrypted config value.*ciphertext/);\n\n await expect(\n makeKmsConfigSource().load({\n secret: {\n ...makeEncryptedValue(),\n context: 'us-prod',\n },\n }),\n ).rejects.toThrow(/Malformed encrypted config value.*context/);\n\n await expect(\n makeKmsConfigSource().load({\n secret: {\n ...makeEncryptedValue(),\n context: {\n environment: 42,\n },\n },\n }),\n ).rejects.toThrow(/Malformed encrypted config value.*context\\.environment/);\n\n await expect(\n makeKmsConfigSource().load({\n secret: {\n __encrypted: 'aws-kms-v2',\n ciphertext: Buffer.from('ciphertext').toString('base64'),\n context: {\n environment: 'us-prod',\n },\n },\n }),\n ).rejects.toThrow(/Malformed encrypted config value.*__encrypted.*aws-kms-v1/);\n });\n\n it('throws on invalid decrypt results', async () => {\n sendMock.mockResolvedValueOnce({});\n await expect(\n makeKmsConfigSource().load({\n secret: makeEncryptedValue(),\n }),\n ).rejects.toThrow(/missing Plaintext/);\n\n sendMock.mockResolvedValueOnce({ Plaintext: new Uint8Array([0xff]) });\n await expect(\n makeKmsConfigSource().load({\n secret: makeEncryptedValue(),\n }),\n ).rejects.toThrow(/not valid UTF-8/);\n });\n\n it('includes the config path when KMS decrypt fails', async () => {\n const cause = new Error('AccessDeniedException');\n sendMock.mockRejectedValue(cause);\n\n let thrown: unknown;\n try {\n await makeKmsConfigSource().load({\n nested: {\n secret: makeEncryptedValue(),\n },\n });\n } catch (error) {\n thrown = error;\n }\n\n assert.instanceOf(thrown, Error);\n assert.match(thrown.message, /KMS decrypt failed.*nested\\.secret/);\n assert.strictEqual(thrown.cause, cause);\n });\n});\n"]}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { DecryptCommand, KMSClient } from '@aws-sdk/client-kms';
|
|
2
|
+
import { assert, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { ConfigLoader, makeKmsConfigSource, makeLiteralConfigSource } from '../index.js';
|
|
5
|
+
const sendMock = vi.hoisted(() => vi.fn());
|
|
6
|
+
vi.mock('@aws-sdk/client-kms', () => ({
|
|
7
|
+
DecryptCommand: vi.fn(function DecryptCommand(input) {
|
|
8
|
+
return { input };
|
|
9
|
+
}),
|
|
10
|
+
KMSClient: vi.fn(function KMSClient() {
|
|
11
|
+
return { send: sendMock };
|
|
12
|
+
}),
|
|
13
|
+
}));
|
|
14
|
+
function makeEncryptedValue(ciphertext = Buffer.from('ciphertext').toString('base64')) {
|
|
15
|
+
return {
|
|
16
|
+
__encrypted: 'aws-kms-v1',
|
|
17
|
+
ciphertext,
|
|
18
|
+
context: {
|
|
19
|
+
environment: 'us-prod',
|
|
20
|
+
},
|
|
21
|
+
metadata: {
|
|
22
|
+
key: 'alias/service-config/us-prod',
|
|
23
|
+
description: 'prairietest postgresql password',
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
describe('makeKmsConfigSource', () => {
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
sendMock.mockReset();
|
|
30
|
+
vi.mocked(DecryptCommand).mockClear();
|
|
31
|
+
vi.mocked(KMSClient).mockClear();
|
|
32
|
+
});
|
|
33
|
+
it('returns an unchanged config and does not create a KMS client when there are no encrypted values', async () => {
|
|
34
|
+
const source = makeKmsConfigSource();
|
|
35
|
+
assert.deepEqual(await source.load({ secret: 'plaintext' }), { secret: 'plaintext' });
|
|
36
|
+
expect(KMSClient).not.toHaveBeenCalled();
|
|
37
|
+
});
|
|
38
|
+
it('decrypts a top-level encrypted string', async () => {
|
|
39
|
+
sendMock.mockResolvedValue({ Plaintext: new TextEncoder().encode('decrypted') });
|
|
40
|
+
const schema = z.object({
|
|
41
|
+
secret: z.string().default(''),
|
|
42
|
+
});
|
|
43
|
+
const loader = new ConfigLoader(schema);
|
|
44
|
+
await loader.loadAndValidate([
|
|
45
|
+
makeLiteralConfigSource({
|
|
46
|
+
secret: makeEncryptedValue(),
|
|
47
|
+
}),
|
|
48
|
+
makeKmsConfigSource(),
|
|
49
|
+
]);
|
|
50
|
+
assert.equal(loader.config.secret, 'decrypted');
|
|
51
|
+
expect(DecryptCommand).toHaveBeenCalledWith({
|
|
52
|
+
CiphertextBlob: Buffer.from('ciphertext'),
|
|
53
|
+
EncryptionContext: {
|
|
54
|
+
environment: 'us-prod',
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
it('ignores metadata when decrypting', async () => {
|
|
59
|
+
sendMock.mockResolvedValue({ Plaintext: new TextEncoder().encode('decrypted') });
|
|
60
|
+
await makeKmsConfigSource().load({
|
|
61
|
+
secret: {
|
|
62
|
+
...makeEncryptedValue(),
|
|
63
|
+
metadata: {
|
|
64
|
+
key: 42,
|
|
65
|
+
description: {
|
|
66
|
+
text: 'metadata is not used by runtime decryption',
|
|
67
|
+
},
|
|
68
|
+
owner: ['course-staff'],
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
expect(DecryptCommand).toHaveBeenCalledWith({
|
|
73
|
+
CiphertextBlob: Buffer.from('ciphertext'),
|
|
74
|
+
EncryptionContext: {
|
|
75
|
+
environment: 'us-prod',
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
it('passes encryption context through to KMS without requiring PrairieLearn-specific keys', async () => {
|
|
80
|
+
sendMock.mockResolvedValue({ Plaintext: new TextEncoder().encode('decrypted') });
|
|
81
|
+
await makeKmsConfigSource().load({
|
|
82
|
+
secret: {
|
|
83
|
+
...makeEncryptedValue(),
|
|
84
|
+
context: {
|
|
85
|
+
deployment: 'self-hosted',
|
|
86
|
+
purpose: 'config',
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
expect(DecryptCommand).toHaveBeenCalledWith({
|
|
91
|
+
CiphertextBlob: Buffer.from('ciphertext'),
|
|
92
|
+
EncryptionContext: {
|
|
93
|
+
deployment: 'self-hosted',
|
|
94
|
+
purpose: 'config',
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
it('uses awsRegion from existing config', async () => {
|
|
99
|
+
sendMock.mockResolvedValue({ Plaintext: new TextEncoder().encode('decrypted') });
|
|
100
|
+
await makeKmsConfigSource().load({
|
|
101
|
+
awsRegion: 'us-west-2',
|
|
102
|
+
secret: makeEncryptedValue(),
|
|
103
|
+
});
|
|
104
|
+
expect(KMSClient).toHaveBeenCalledWith({ region: 'us-west-2' });
|
|
105
|
+
});
|
|
106
|
+
it('returns the full transformed config when encrypted values exist', async () => {
|
|
107
|
+
sendMock.mockResolvedValue({ Plaintext: new TextEncoder().encode('decrypted') });
|
|
108
|
+
const result = await makeKmsConfigSource().load({
|
|
109
|
+
database: {
|
|
110
|
+
host: 'db.example.com',
|
|
111
|
+
password: makeEncryptedValue(),
|
|
112
|
+
},
|
|
113
|
+
courseDirs: ['exampleCourse'],
|
|
114
|
+
});
|
|
115
|
+
assert.deepEqual(result, {
|
|
116
|
+
database: {
|
|
117
|
+
host: 'db.example.com',
|
|
118
|
+
password: 'decrypted',
|
|
119
|
+
},
|
|
120
|
+
courseDirs: ['exampleCourse'],
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
it('decrypts nested object and array values', async () => {
|
|
124
|
+
sendMock
|
|
125
|
+
.mockResolvedValueOnce({ Plaintext: new TextEncoder().encode('nested') })
|
|
126
|
+
.mockResolvedValueOnce({ Plaintext: new TextEncoder().encode('array') });
|
|
127
|
+
const schema = z.object({
|
|
128
|
+
nested: z
|
|
129
|
+
.object({
|
|
130
|
+
secret: z.string().default(''),
|
|
131
|
+
unchanged: z.string().default('kept'),
|
|
132
|
+
})
|
|
133
|
+
.default({ secret: '', unchanged: 'kept' }),
|
|
134
|
+
values: z.array(z.union([z.string(), z.object({ secret: z.string() })])).default([]),
|
|
135
|
+
});
|
|
136
|
+
const loader = new ConfigLoader(schema);
|
|
137
|
+
await loader.loadAndValidate([
|
|
138
|
+
makeLiteralConfigSource({
|
|
139
|
+
nested: {
|
|
140
|
+
secret: makeEncryptedValue(),
|
|
141
|
+
unchanged: 'kept',
|
|
142
|
+
},
|
|
143
|
+
values: ['first', { secret: makeEncryptedValue() }],
|
|
144
|
+
}),
|
|
145
|
+
makeKmsConfigSource(),
|
|
146
|
+
]);
|
|
147
|
+
assert.deepEqual(loader.config, {
|
|
148
|
+
nested: {
|
|
149
|
+
secret: 'nested',
|
|
150
|
+
unchanged: 'kept',
|
|
151
|
+
},
|
|
152
|
+
values: ['first', { secret: 'array' }],
|
|
153
|
+
});
|
|
154
|
+
expect(KMSClient).toHaveBeenCalledTimes(1);
|
|
155
|
+
});
|
|
156
|
+
it('does not mutate the source object when decrypting encrypted values', async () => {
|
|
157
|
+
sendMock.mockResolvedValue({ Plaintext: new TextEncoder().encode('decrypted') });
|
|
158
|
+
const encryptedValue = makeEncryptedValue();
|
|
159
|
+
const sourceConfig = {
|
|
160
|
+
secret: encryptedValue,
|
|
161
|
+
};
|
|
162
|
+
const result = await makeKmsConfigSource().load(sourceConfig);
|
|
163
|
+
assert.deepEqual(encryptedValue, makeEncryptedValue());
|
|
164
|
+
assert.deepEqual(sourceConfig, { secret: encryptedValue });
|
|
165
|
+
assert.deepEqual(result, { secret: 'decrypted' });
|
|
166
|
+
});
|
|
167
|
+
it('throws on malformed encrypted values', async () => {
|
|
168
|
+
await expect(makeKmsConfigSource().load({
|
|
169
|
+
secret: {
|
|
170
|
+
__encrypted: 'aws-kms-v1',
|
|
171
|
+
ciphertext: Buffer.from('ciphertext').toString('base64'),
|
|
172
|
+
},
|
|
173
|
+
})).rejects.toThrow(/Malformed encrypted config value.*context/);
|
|
174
|
+
await expect(makeKmsConfigSource().load({
|
|
175
|
+
secret: {
|
|
176
|
+
__encrypted: 'aws-kms-v1',
|
|
177
|
+
context: {
|
|
178
|
+
environment: 'us-prod',
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
})).rejects.toThrow(/Malformed encrypted config value.*ciphertext/);
|
|
182
|
+
await expect(makeKmsConfigSource().load({
|
|
183
|
+
secret: {
|
|
184
|
+
...makeEncryptedValue(),
|
|
185
|
+
context: 'us-prod',
|
|
186
|
+
},
|
|
187
|
+
})).rejects.toThrow(/Malformed encrypted config value.*context/);
|
|
188
|
+
await expect(makeKmsConfigSource().load({
|
|
189
|
+
secret: {
|
|
190
|
+
...makeEncryptedValue(),
|
|
191
|
+
context: {
|
|
192
|
+
environment: 42,
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
})).rejects.toThrow(/Malformed encrypted config value.*context\.environment/);
|
|
196
|
+
await expect(makeKmsConfigSource().load({
|
|
197
|
+
secret: {
|
|
198
|
+
__encrypted: 'aws-kms-v2',
|
|
199
|
+
ciphertext: Buffer.from('ciphertext').toString('base64'),
|
|
200
|
+
context: {
|
|
201
|
+
environment: 'us-prod',
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
})).rejects.toThrow(/Malformed encrypted config value.*__encrypted.*aws-kms-v1/);
|
|
205
|
+
});
|
|
206
|
+
it('throws on invalid decrypt results', async () => {
|
|
207
|
+
sendMock.mockResolvedValueOnce({});
|
|
208
|
+
await expect(makeKmsConfigSource().load({
|
|
209
|
+
secret: makeEncryptedValue(),
|
|
210
|
+
})).rejects.toThrow(/missing Plaintext/);
|
|
211
|
+
sendMock.mockResolvedValueOnce({ Plaintext: new Uint8Array([0xff]) });
|
|
212
|
+
await expect(makeKmsConfigSource().load({
|
|
213
|
+
secret: makeEncryptedValue(),
|
|
214
|
+
})).rejects.toThrow(/not valid UTF-8/);
|
|
215
|
+
});
|
|
216
|
+
it('includes the config path when KMS decrypt fails', async () => {
|
|
217
|
+
const cause = new Error('AccessDeniedException');
|
|
218
|
+
sendMock.mockRejectedValue(cause);
|
|
219
|
+
let thrown;
|
|
220
|
+
try {
|
|
221
|
+
await makeKmsConfigSource().load({
|
|
222
|
+
nested: {
|
|
223
|
+
secret: makeEncryptedValue(),
|
|
224
|
+
},
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
catch (error) {
|
|
228
|
+
thrown = error;
|
|
229
|
+
}
|
|
230
|
+
assert.instanceOf(thrown, Error);
|
|
231
|
+
assert.match(thrown.message, /KMS decrypt failed.*nested\.secret/);
|
|
232
|
+
assert.strictEqual(thrown.cause, cause);
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
//# sourceMappingURL=kms.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"kms.test.js","sourceRoot":"","sources":["../../src/sources/kms.test.ts"],"names":[],"mappings":"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","sourcesContent":["import { DecryptCommand, KMSClient } from '@aws-sdk/client-kms';\nimport { assert, beforeEach, describe, expect, it, vi } from 'vitest';\nimport { z } from 'zod';\n\nimport { ConfigLoader, makeKmsConfigSource, makeLiteralConfigSource } from '../index.js';\n\nconst sendMock = vi.hoisted(() => vi.fn());\n\nvi.mock('@aws-sdk/client-kms', () => ({\n DecryptCommand: vi.fn(function DecryptCommand(input: unknown) {\n return { input };\n }),\n KMSClient: vi.fn(function KMSClient() {\n return { send: sendMock };\n }),\n}));\n\nfunction makeEncryptedValue(ciphertext = Buffer.from('ciphertext').toString('base64')) {\n return {\n __encrypted: 'aws-kms-v1',\n ciphertext,\n context: {\n environment: 'us-prod',\n },\n metadata: {\n key: 'alias/service-config/us-prod',\n description: 'prairietest postgresql password',\n },\n };\n}\n\ndescribe('makeKmsConfigSource', () => {\n beforeEach(() => {\n sendMock.mockReset();\n vi.mocked(DecryptCommand).mockClear();\n vi.mocked(KMSClient).mockClear();\n });\n\n it('returns an unchanged config and does not create a KMS client when there are no encrypted values', async () => {\n const source = makeKmsConfigSource();\n\n assert.deepEqual(await source.load({ secret: 'plaintext' }), { secret: 'plaintext' });\n expect(KMSClient).not.toHaveBeenCalled();\n });\n\n it('decrypts a top-level encrypted string', async () => {\n sendMock.mockResolvedValue({ Plaintext: new TextEncoder().encode('decrypted') });\n const schema = z.object({\n secret: z.string().default(''),\n });\n const loader = new ConfigLoader(schema);\n\n await loader.loadAndValidate([\n makeLiteralConfigSource({\n secret: makeEncryptedValue(),\n }),\n makeKmsConfigSource(),\n ]);\n\n assert.equal(loader.config.secret, 'decrypted');\n expect(DecryptCommand).toHaveBeenCalledWith({\n CiphertextBlob: Buffer.from('ciphertext'),\n EncryptionContext: {\n environment: 'us-prod',\n },\n });\n });\n\n it('ignores metadata when decrypting', async () => {\n sendMock.mockResolvedValue({ Plaintext: new TextEncoder().encode('decrypted') });\n\n await makeKmsConfigSource().load({\n secret: {\n ...makeEncryptedValue(),\n metadata: {\n key: 42,\n description: {\n text: 'metadata is not used by runtime decryption',\n },\n owner: ['course-staff'],\n },\n },\n });\n\n expect(DecryptCommand).toHaveBeenCalledWith({\n CiphertextBlob: Buffer.from('ciphertext'),\n EncryptionContext: {\n environment: 'us-prod',\n },\n });\n });\n\n it('passes encryption context through to KMS without requiring PrairieLearn-specific keys', async () => {\n sendMock.mockResolvedValue({ Plaintext: new TextEncoder().encode('decrypted') });\n\n await makeKmsConfigSource().load({\n secret: {\n ...makeEncryptedValue(),\n context: {\n deployment: 'self-hosted',\n purpose: 'config',\n },\n },\n });\n\n expect(DecryptCommand).toHaveBeenCalledWith({\n CiphertextBlob: Buffer.from('ciphertext'),\n EncryptionContext: {\n deployment: 'self-hosted',\n purpose: 'config',\n },\n });\n });\n\n it('uses awsRegion from existing config', async () => {\n sendMock.mockResolvedValue({ Plaintext: new TextEncoder().encode('decrypted') });\n\n await makeKmsConfigSource().load({\n awsRegion: 'us-west-2',\n secret: makeEncryptedValue(),\n });\n\n expect(KMSClient).toHaveBeenCalledWith({ region: 'us-west-2' });\n });\n\n it('returns the full transformed config when encrypted values exist', async () => {\n sendMock.mockResolvedValue({ Plaintext: new TextEncoder().encode('decrypted') });\n\n const result = await makeKmsConfigSource().load({\n database: {\n host: 'db.example.com',\n password: makeEncryptedValue(),\n },\n courseDirs: ['exampleCourse'],\n });\n\n assert.deepEqual(result, {\n database: {\n host: 'db.example.com',\n password: 'decrypted',\n },\n courseDirs: ['exampleCourse'],\n });\n });\n\n it('decrypts nested object and array values', async () => {\n sendMock\n .mockResolvedValueOnce({ Plaintext: new TextEncoder().encode('nested') })\n .mockResolvedValueOnce({ Plaintext: new TextEncoder().encode('array') });\n const schema = z.object({\n nested: z\n .object({\n secret: z.string().default(''),\n unchanged: z.string().default('kept'),\n })\n .default({ secret: '', unchanged: 'kept' }),\n values: z.array(z.union([z.string(), z.object({ secret: z.string() })])).default([]),\n });\n const loader = new ConfigLoader(schema);\n\n await loader.loadAndValidate([\n makeLiteralConfigSource({\n nested: {\n secret: makeEncryptedValue(),\n unchanged: 'kept',\n },\n values: ['first', { secret: makeEncryptedValue() }],\n }),\n makeKmsConfigSource(),\n ]);\n\n assert.deepEqual(loader.config, {\n nested: {\n secret: 'nested',\n unchanged: 'kept',\n },\n values: ['first', { secret: 'array' }],\n });\n expect(KMSClient).toHaveBeenCalledTimes(1);\n });\n\n it('does not mutate the source object when decrypting encrypted values', async () => {\n sendMock.mockResolvedValue({ Plaintext: new TextEncoder().encode('decrypted') });\n const encryptedValue = makeEncryptedValue();\n const sourceConfig = {\n secret: encryptedValue,\n };\n\n const result = await makeKmsConfigSource().load(sourceConfig);\n\n assert.deepEqual(encryptedValue, makeEncryptedValue());\n assert.deepEqual(sourceConfig, { secret: encryptedValue });\n assert.deepEqual(result, { secret: 'decrypted' });\n });\n\n it('throws on malformed encrypted values', async () => {\n await expect(\n makeKmsConfigSource().load({\n secret: {\n __encrypted: 'aws-kms-v1',\n ciphertext: Buffer.from('ciphertext').toString('base64'),\n },\n }),\n ).rejects.toThrow(/Malformed encrypted config value.*context/);\n\n await expect(\n makeKmsConfigSource().load({\n secret: {\n __encrypted: 'aws-kms-v1',\n context: {\n environment: 'us-prod',\n },\n },\n }),\n ).rejects.toThrow(/Malformed encrypted config value.*ciphertext/);\n\n await expect(\n makeKmsConfigSource().load({\n secret: {\n ...makeEncryptedValue(),\n context: 'us-prod',\n },\n }),\n ).rejects.toThrow(/Malformed encrypted config value.*context/);\n\n await expect(\n makeKmsConfigSource().load({\n secret: {\n ...makeEncryptedValue(),\n context: {\n environment: 42,\n },\n },\n }),\n ).rejects.toThrow(/Malformed encrypted config value.*context\\.environment/);\n\n await expect(\n makeKmsConfigSource().load({\n secret: {\n __encrypted: 'aws-kms-v2',\n ciphertext: Buffer.from('ciphertext').toString('base64'),\n context: {\n environment: 'us-prod',\n },\n },\n }),\n ).rejects.toThrow(/Malformed encrypted config value.*__encrypted.*aws-kms-v1/);\n });\n\n it('throws on invalid decrypt results', async () => {\n sendMock.mockResolvedValueOnce({});\n await expect(\n makeKmsConfigSource().load({\n secret: makeEncryptedValue(),\n }),\n ).rejects.toThrow(/missing Plaintext/);\n\n sendMock.mockResolvedValueOnce({ Plaintext: new Uint8Array([0xff]) });\n await expect(\n makeKmsConfigSource().load({\n secret: makeEncryptedValue(),\n }),\n ).rejects.toThrow(/not valid UTF-8/);\n });\n\n it('includes the config path when KMS decrypt fails', async () => {\n const cause = new Error('AccessDeniedException');\n sendMock.mockRejectedValue(cause);\n\n let thrown: unknown;\n try {\n await makeKmsConfigSource().load({\n nested: {\n secret: makeEncryptedValue(),\n },\n });\n } catch (error) {\n thrown = error;\n }\n\n assert.instanceOf(thrown, Error);\n assert.match(thrown.message, /KMS decrypt failed.*nested\\.secret/);\n assert.strictEqual(thrown.cause, cause);\n });\n});\n"]}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,cAAc,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAErD,MAAM,WAAW,YAAY,CAAC,CAAC,SAAS,cAAc,GAAG,cAAc;IACrE,IAAI,EAAE,CAAC,cAAc,EAAE,CAAC,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC;CAClD","sourcesContent":["export type AbstractConfig = Record<string, unknown>;\n\nexport interface ConfigSource<T extends AbstractConfig = AbstractConfig> {\n load: (existingConfig: T) => Promise<Partial<T>>;\n}\n"]}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"","sourcesContent":["export type AbstractConfig = Record<string, unknown>;\n\nexport interface ConfigSource<T extends AbstractConfig = AbstractConfig> {\n load: (existingConfig: T) => Promise<Partial<T>>;\n}\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@prairielearn/config",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -17,9 +17,10 @@
|
|
|
17
17
|
"test": "vitest run --coverage"
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
|
-
"@aws-sdk/client-ec2": "^3.
|
|
21
|
-
"@aws-sdk/client-
|
|
22
|
-
"@
|
|
20
|
+
"@aws-sdk/client-ec2": "^3.1023.0",
|
|
21
|
+
"@aws-sdk/client-kms": "^3.1023.0",
|
|
22
|
+
"@aws-sdk/client-secrets-manager": "^3.1023.0",
|
|
23
|
+
"@prairielearn/aws-imds": "^3.0.3",
|
|
23
24
|
"es-toolkit": "^1.45.1",
|
|
24
25
|
"fs-extra": "^11.3.4",
|
|
25
26
|
"zod": "^3.25.76"
|
|
@@ -27,12 +28,12 @@
|
|
|
27
28
|
"devDependencies": {
|
|
28
29
|
"@prairielearn/tsconfig": "^2.0.0",
|
|
29
30
|
"@types/fs-extra": "^11.0.4",
|
|
30
|
-
"@types/node": "^24.
|
|
31
|
+
"@types/node": "^24.12.2",
|
|
31
32
|
"@typescript/native-preview": "^7.0.0-dev.20260305.1",
|
|
32
|
-
"@vitest/coverage-v8": "^4.
|
|
33
|
+
"@vitest/coverage-v8": "^4.1.2",
|
|
33
34
|
"tmp-promise": "^3.0.3",
|
|
34
35
|
"tsx": "^4.21.0",
|
|
35
36
|
"typescript": "^5.9.3",
|
|
36
|
-
"vitest": "^4.
|
|
37
|
+
"vitest": "^4.1.2"
|
|
37
38
|
}
|
|
38
39
|
}
|
package/src/index.ts
CHANGED
|
@@ -6,11 +6,10 @@ import { z } from 'zod';
|
|
|
6
6
|
|
|
7
7
|
import { fetchInstanceHostname, fetchInstanceIdentity } from '@prairielearn/aws-imds';
|
|
8
8
|
|
|
9
|
-
type AbstractConfig
|
|
9
|
+
import type { AbstractConfig, ConfigSource } from './types.js';
|
|
10
10
|
|
|
11
|
-
export
|
|
12
|
-
|
|
13
|
-
}
|
|
11
|
+
export { makeKmsConfigSource } from './sources/kms.js';
|
|
12
|
+
export type { AbstractConfig, ConfigSource } from './types.js';
|
|
14
13
|
|
|
15
14
|
export function makeLiteralConfigSource(config: AbstractConfig) {
|
|
16
15
|
return {
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { DecryptCommand, KMSClient } from '@aws-sdk/client-kms';
|
|
2
|
+
import { assert, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
|
|
5
|
+
import { ConfigLoader, makeKmsConfigSource, makeLiteralConfigSource } from '../index.js';
|
|
6
|
+
|
|
7
|
+
const sendMock = vi.hoisted(() => vi.fn());
|
|
8
|
+
|
|
9
|
+
vi.mock('@aws-sdk/client-kms', () => ({
|
|
10
|
+
DecryptCommand: vi.fn(function DecryptCommand(input: unknown) {
|
|
11
|
+
return { input };
|
|
12
|
+
}),
|
|
13
|
+
KMSClient: vi.fn(function KMSClient() {
|
|
14
|
+
return { send: sendMock };
|
|
15
|
+
}),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
function makeEncryptedValue(ciphertext = Buffer.from('ciphertext').toString('base64')) {
|
|
19
|
+
return {
|
|
20
|
+
__encrypted: 'aws-kms-v1',
|
|
21
|
+
ciphertext,
|
|
22
|
+
context: {
|
|
23
|
+
environment: 'us-prod',
|
|
24
|
+
},
|
|
25
|
+
metadata: {
|
|
26
|
+
key: 'alias/service-config/us-prod',
|
|
27
|
+
description: 'prairietest postgresql password',
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('makeKmsConfigSource', () => {
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
sendMock.mockReset();
|
|
35
|
+
vi.mocked(DecryptCommand).mockClear();
|
|
36
|
+
vi.mocked(KMSClient).mockClear();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('returns an unchanged config and does not create a KMS client when there are no encrypted values', async () => {
|
|
40
|
+
const source = makeKmsConfigSource();
|
|
41
|
+
|
|
42
|
+
assert.deepEqual(await source.load({ secret: 'plaintext' }), { secret: 'plaintext' });
|
|
43
|
+
expect(KMSClient).not.toHaveBeenCalled();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('decrypts a top-level encrypted string', async () => {
|
|
47
|
+
sendMock.mockResolvedValue({ Plaintext: new TextEncoder().encode('decrypted') });
|
|
48
|
+
const schema = z.object({
|
|
49
|
+
secret: z.string().default(''),
|
|
50
|
+
});
|
|
51
|
+
const loader = new ConfigLoader(schema);
|
|
52
|
+
|
|
53
|
+
await loader.loadAndValidate([
|
|
54
|
+
makeLiteralConfigSource({
|
|
55
|
+
secret: makeEncryptedValue(),
|
|
56
|
+
}),
|
|
57
|
+
makeKmsConfigSource(),
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
assert.equal(loader.config.secret, 'decrypted');
|
|
61
|
+
expect(DecryptCommand).toHaveBeenCalledWith({
|
|
62
|
+
CiphertextBlob: Buffer.from('ciphertext'),
|
|
63
|
+
EncryptionContext: {
|
|
64
|
+
environment: 'us-prod',
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('ignores metadata when decrypting', async () => {
|
|
70
|
+
sendMock.mockResolvedValue({ Plaintext: new TextEncoder().encode('decrypted') });
|
|
71
|
+
|
|
72
|
+
await makeKmsConfigSource().load({
|
|
73
|
+
secret: {
|
|
74
|
+
...makeEncryptedValue(),
|
|
75
|
+
metadata: {
|
|
76
|
+
key: 42,
|
|
77
|
+
description: {
|
|
78
|
+
text: 'metadata is not used by runtime decryption',
|
|
79
|
+
},
|
|
80
|
+
owner: ['course-staff'],
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
expect(DecryptCommand).toHaveBeenCalledWith({
|
|
86
|
+
CiphertextBlob: Buffer.from('ciphertext'),
|
|
87
|
+
EncryptionContext: {
|
|
88
|
+
environment: 'us-prod',
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('passes encryption context through to KMS without requiring PrairieLearn-specific keys', async () => {
|
|
94
|
+
sendMock.mockResolvedValue({ Plaintext: new TextEncoder().encode('decrypted') });
|
|
95
|
+
|
|
96
|
+
await makeKmsConfigSource().load({
|
|
97
|
+
secret: {
|
|
98
|
+
...makeEncryptedValue(),
|
|
99
|
+
context: {
|
|
100
|
+
deployment: 'self-hosted',
|
|
101
|
+
purpose: 'config',
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
expect(DecryptCommand).toHaveBeenCalledWith({
|
|
107
|
+
CiphertextBlob: Buffer.from('ciphertext'),
|
|
108
|
+
EncryptionContext: {
|
|
109
|
+
deployment: 'self-hosted',
|
|
110
|
+
purpose: 'config',
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('uses awsRegion from existing config', async () => {
|
|
116
|
+
sendMock.mockResolvedValue({ Plaintext: new TextEncoder().encode('decrypted') });
|
|
117
|
+
|
|
118
|
+
await makeKmsConfigSource().load({
|
|
119
|
+
awsRegion: 'us-west-2',
|
|
120
|
+
secret: makeEncryptedValue(),
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
expect(KMSClient).toHaveBeenCalledWith({ region: 'us-west-2' });
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('returns the full transformed config when encrypted values exist', async () => {
|
|
127
|
+
sendMock.mockResolvedValue({ Plaintext: new TextEncoder().encode('decrypted') });
|
|
128
|
+
|
|
129
|
+
const result = await makeKmsConfigSource().load({
|
|
130
|
+
database: {
|
|
131
|
+
host: 'db.example.com',
|
|
132
|
+
password: makeEncryptedValue(),
|
|
133
|
+
},
|
|
134
|
+
courseDirs: ['exampleCourse'],
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
assert.deepEqual(result, {
|
|
138
|
+
database: {
|
|
139
|
+
host: 'db.example.com',
|
|
140
|
+
password: 'decrypted',
|
|
141
|
+
},
|
|
142
|
+
courseDirs: ['exampleCourse'],
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('decrypts nested object and array values', async () => {
|
|
147
|
+
sendMock
|
|
148
|
+
.mockResolvedValueOnce({ Plaintext: new TextEncoder().encode('nested') })
|
|
149
|
+
.mockResolvedValueOnce({ Plaintext: new TextEncoder().encode('array') });
|
|
150
|
+
const schema = z.object({
|
|
151
|
+
nested: z
|
|
152
|
+
.object({
|
|
153
|
+
secret: z.string().default(''),
|
|
154
|
+
unchanged: z.string().default('kept'),
|
|
155
|
+
})
|
|
156
|
+
.default({ secret: '', unchanged: 'kept' }),
|
|
157
|
+
values: z.array(z.union([z.string(), z.object({ secret: z.string() })])).default([]),
|
|
158
|
+
});
|
|
159
|
+
const loader = new ConfigLoader(schema);
|
|
160
|
+
|
|
161
|
+
await loader.loadAndValidate([
|
|
162
|
+
makeLiteralConfigSource({
|
|
163
|
+
nested: {
|
|
164
|
+
secret: makeEncryptedValue(),
|
|
165
|
+
unchanged: 'kept',
|
|
166
|
+
},
|
|
167
|
+
values: ['first', { secret: makeEncryptedValue() }],
|
|
168
|
+
}),
|
|
169
|
+
makeKmsConfigSource(),
|
|
170
|
+
]);
|
|
171
|
+
|
|
172
|
+
assert.deepEqual(loader.config, {
|
|
173
|
+
nested: {
|
|
174
|
+
secret: 'nested',
|
|
175
|
+
unchanged: 'kept',
|
|
176
|
+
},
|
|
177
|
+
values: ['first', { secret: 'array' }],
|
|
178
|
+
});
|
|
179
|
+
expect(KMSClient).toHaveBeenCalledTimes(1);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('does not mutate the source object when decrypting encrypted values', async () => {
|
|
183
|
+
sendMock.mockResolvedValue({ Plaintext: new TextEncoder().encode('decrypted') });
|
|
184
|
+
const encryptedValue = makeEncryptedValue();
|
|
185
|
+
const sourceConfig = {
|
|
186
|
+
secret: encryptedValue,
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const result = await makeKmsConfigSource().load(sourceConfig);
|
|
190
|
+
|
|
191
|
+
assert.deepEqual(encryptedValue, makeEncryptedValue());
|
|
192
|
+
assert.deepEqual(sourceConfig, { secret: encryptedValue });
|
|
193
|
+
assert.deepEqual(result, { secret: 'decrypted' });
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('throws on malformed encrypted values', async () => {
|
|
197
|
+
await expect(
|
|
198
|
+
makeKmsConfigSource().load({
|
|
199
|
+
secret: {
|
|
200
|
+
__encrypted: 'aws-kms-v1',
|
|
201
|
+
ciphertext: Buffer.from('ciphertext').toString('base64'),
|
|
202
|
+
},
|
|
203
|
+
}),
|
|
204
|
+
).rejects.toThrow(/Malformed encrypted config value.*context/);
|
|
205
|
+
|
|
206
|
+
await expect(
|
|
207
|
+
makeKmsConfigSource().load({
|
|
208
|
+
secret: {
|
|
209
|
+
__encrypted: 'aws-kms-v1',
|
|
210
|
+
context: {
|
|
211
|
+
environment: 'us-prod',
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
}),
|
|
215
|
+
).rejects.toThrow(/Malformed encrypted config value.*ciphertext/);
|
|
216
|
+
|
|
217
|
+
await expect(
|
|
218
|
+
makeKmsConfigSource().load({
|
|
219
|
+
secret: {
|
|
220
|
+
...makeEncryptedValue(),
|
|
221
|
+
context: 'us-prod',
|
|
222
|
+
},
|
|
223
|
+
}),
|
|
224
|
+
).rejects.toThrow(/Malformed encrypted config value.*context/);
|
|
225
|
+
|
|
226
|
+
await expect(
|
|
227
|
+
makeKmsConfigSource().load({
|
|
228
|
+
secret: {
|
|
229
|
+
...makeEncryptedValue(),
|
|
230
|
+
context: {
|
|
231
|
+
environment: 42,
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
}),
|
|
235
|
+
).rejects.toThrow(/Malformed encrypted config value.*context\.environment/);
|
|
236
|
+
|
|
237
|
+
await expect(
|
|
238
|
+
makeKmsConfigSource().load({
|
|
239
|
+
secret: {
|
|
240
|
+
__encrypted: 'aws-kms-v2',
|
|
241
|
+
ciphertext: Buffer.from('ciphertext').toString('base64'),
|
|
242
|
+
context: {
|
|
243
|
+
environment: 'us-prod',
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
}),
|
|
247
|
+
).rejects.toThrow(/Malformed encrypted config value.*__encrypted.*aws-kms-v1/);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('throws on invalid decrypt results', async () => {
|
|
251
|
+
sendMock.mockResolvedValueOnce({});
|
|
252
|
+
await expect(
|
|
253
|
+
makeKmsConfigSource().load({
|
|
254
|
+
secret: makeEncryptedValue(),
|
|
255
|
+
}),
|
|
256
|
+
).rejects.toThrow(/missing Plaintext/);
|
|
257
|
+
|
|
258
|
+
sendMock.mockResolvedValueOnce({ Plaintext: new Uint8Array([0xff]) });
|
|
259
|
+
await expect(
|
|
260
|
+
makeKmsConfigSource().load({
|
|
261
|
+
secret: makeEncryptedValue(),
|
|
262
|
+
}),
|
|
263
|
+
).rejects.toThrow(/not valid UTF-8/);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('includes the config path when KMS decrypt fails', async () => {
|
|
267
|
+
const cause = new Error('AccessDeniedException');
|
|
268
|
+
sendMock.mockRejectedValue(cause);
|
|
269
|
+
|
|
270
|
+
let thrown: unknown;
|
|
271
|
+
try {
|
|
272
|
+
await makeKmsConfigSource().load({
|
|
273
|
+
nested: {
|
|
274
|
+
secret: makeEncryptedValue(),
|
|
275
|
+
},
|
|
276
|
+
});
|
|
277
|
+
} catch (error) {
|
|
278
|
+
thrown = error;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
assert.instanceOf(thrown, Error);
|
|
282
|
+
assert.match(thrown.message, /KMS decrypt failed.*nested\.secret/);
|
|
283
|
+
assert.strictEqual(thrown.cause, cause);
|
|
284
|
+
});
|
|
285
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { DecryptCommand, type DecryptCommandOutput, KMSClient } from '@aws-sdk/client-kms';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
import type { ConfigSource } from '../types.js';
|
|
5
|
+
|
|
6
|
+
const EncryptedValueSchema = z.object({
|
|
7
|
+
__encrypted: z.literal('aws-kms-v1'),
|
|
8
|
+
ciphertext: z.string(),
|
|
9
|
+
context: z.record(z.string(), z.string()),
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
13
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function isEncryptedValue(value: unknown): boolean {
|
|
17
|
+
return isPlainObject(value) && Object.hasOwn(value, '__encrypted');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function formatConfigPath(path: (string | number)[]): string {
|
|
21
|
+
return path.length === 0
|
|
22
|
+
? '<root>'
|
|
23
|
+
: path
|
|
24
|
+
.map((part, index) =>
|
|
25
|
+
typeof part === 'number' ? `[${part}]` : index === 0 ? part : `.${part}`,
|
|
26
|
+
)
|
|
27
|
+
.join('');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function formatZodIssues(error: z.ZodError): string {
|
|
31
|
+
return error.issues
|
|
32
|
+
.map((issue) => `${issue.path.join('.') || '<value>'}: ${issue.message}`)
|
|
33
|
+
.join('; ');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function parseEncryptedValue(value: unknown, path: (string | number)[]) {
|
|
37
|
+
const result = EncryptedValueSchema.safeParse(value);
|
|
38
|
+
if (!result.success) {
|
|
39
|
+
throw new Error(
|
|
40
|
+
`Malformed encrypted config value at ${formatConfigPath(path)}: ${formatZodIssues(result.error)}`,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return result.data;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function decryptEncryptedValue(
|
|
48
|
+
value: unknown,
|
|
49
|
+
path: (string | number)[],
|
|
50
|
+
getKmsClient: () => KMSClient,
|
|
51
|
+
): Promise<string> {
|
|
52
|
+
const encryptedValue = parseEncryptedValue(value, path);
|
|
53
|
+
const ciphertextBlob = Buffer.from(encryptedValue.ciphertext, 'base64');
|
|
54
|
+
const kmsClient = getKmsClient();
|
|
55
|
+
|
|
56
|
+
let result: DecryptCommandOutput;
|
|
57
|
+
try {
|
|
58
|
+
result = await kmsClient.send(
|
|
59
|
+
new DecryptCommand({
|
|
60
|
+
CiphertextBlob: ciphertextBlob,
|
|
61
|
+
EncryptionContext: encryptedValue.context,
|
|
62
|
+
}),
|
|
63
|
+
);
|
|
64
|
+
} catch (error) {
|
|
65
|
+
throw new Error(`KMS decrypt failed for encrypted config value at ${formatConfigPath(path)}`, {
|
|
66
|
+
cause: error,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (!result.Plaintext) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
`KMS decrypt result missing Plaintext for encrypted config value at ${formatConfigPath(path)}`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(result.Plaintext);
|
|
78
|
+
} catch (error) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
`KMS decrypt result Plaintext is not valid UTF-8 for encrypted config value at ${formatConfigPath(path)}`,
|
|
81
|
+
{ cause: error },
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function decryptEncryptedValuesInPlace(
|
|
87
|
+
value: unknown,
|
|
88
|
+
path: (string | number)[],
|
|
89
|
+
getKmsClient: () => KMSClient,
|
|
90
|
+
): Promise<unknown> {
|
|
91
|
+
if (isEncryptedValue(value)) {
|
|
92
|
+
return await decryptEncryptedValue(value, path, getKmsClient);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (Array.isArray(value)) {
|
|
96
|
+
for (const [index, item] of value.entries()) {
|
|
97
|
+
value[index] = await decryptEncryptedValuesInPlace(item, [...path, index], getKmsClient);
|
|
98
|
+
}
|
|
99
|
+
return value;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (!isPlainObject(value)) {
|
|
103
|
+
return value;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
for (const [key, childValue] of Object.entries(value)) {
|
|
107
|
+
value[key] = await decryptEncryptedValuesInPlace(childValue, [...path, key], getKmsClient);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return value;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function makeKmsConfigSource(): ConfigSource {
|
|
114
|
+
return {
|
|
115
|
+
load: async (existingConfig) => {
|
|
116
|
+
let kmsClient: KMSClient | undefined;
|
|
117
|
+
|
|
118
|
+
// The client is created lazily so this source remains a no-op when there
|
|
119
|
+
// are no encrypted values. If a client is created, it's then reused for
|
|
120
|
+
// all decrypts in this load.
|
|
121
|
+
const getKmsClient = () => {
|
|
122
|
+
if (!kmsClient) {
|
|
123
|
+
const region =
|
|
124
|
+
typeof existingConfig.awsRegion === 'string' ? existingConfig.awsRegion : undefined;
|
|
125
|
+
|
|
126
|
+
// We don't care about sharing configs between clients here; this
|
|
127
|
+
// client is only used once, typically at application startup.
|
|
128
|
+
// eslint-disable-next-line @prairielearn/aws-client-shared-config
|
|
129
|
+
kmsClient = new KMSClient({ region });
|
|
130
|
+
}
|
|
131
|
+
return kmsClient;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const resolvedConfig = structuredClone(existingConfig);
|
|
135
|
+
await decryptEncryptedValuesInPlace(resolvedConfig, [], getKmsClient);
|
|
136
|
+
return resolvedConfig as Partial<typeof existingConfig>;
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|