diff --git a/.changeset/csfle-kms-providers-redacted.md b/.changeset/csfle-kms-providers-redacted.md new file mode 100644 index 0000000000..9d230efb3f --- /dev/null +++ b/.changeset/csfle-kms-providers-redacted.md @@ -0,0 +1,18 @@ +--- +"@objectstack/spec": patch +--- + +fix(spec): redact mongo CSFLE `kmsProviders` key material on datasource reads (#13602) + +`redactDatasourceConfig('mongodb', …)` used to serve the CSFLE KMS secret material +inside the `options` passthrough back in cleartext with no `redactedKeys` entry: +`options.autoEncryption.kmsProviders.aws.secretAccessKey` (and `aws.sessionToken`), +`azure.clientSecret`, `gcp.privateKey`, and `local.key`. All five positions are now +on `passthroughSecretPaths`, measured against `mongodb@7.5.0` both with the optional +`mongodb-client-encryption` dependency installed (the client reads every leaf at +construction) and without it (construction throws `MongoMissingDependencyError` +before reading any of them). The same families' identity halves (`aws.accessKeyId`, +`azure.tenantId`/`clientId`, `gcp.email`) and unmeasured neighbours +(`kmip.endpoint`, `keyVaultNamespace`, `schemaMap`) stay served unchanged. An +untouched Save still round-trips byte-identically: `restoreRedactedConfig` mirrors +the redactor structurally, so the stored CSFLE config keeps working. diff --git a/packages/services/service-datasource/src/__tests__/datasource-config-redaction.test.ts b/packages/services/service-datasource/src/__tests__/datasource-config-redaction.test.ts index cbd799ab11..229195b0c9 100644 --- a/packages/services/service-datasource/src/__tests__/datasource-config-redaction.test.ts +++ b/packages/services/service-datasource/src/__tests__/datasource-config-redaction.test.ts @@ -442,6 +442,51 @@ describe('#9040 — the passthrough spelling, both halves at the service door', expect((records[0].config!.options as any).auth.password).toBe('PLAINTEXT-IN-METADATA'); }); + it('an untouched round-trip keeps a working CSFLE config byte-identical — every kmsProviders family', async () => { + // The #13602 round-trip question, answered at the door that owns it: the + // read path drops the KMS key material (measured read by mongodb@7.5.0 + // with mongodb-client-encryption installed; loud MongoMissingDependencyError + // without it), and an untouched Save grafts every stored leaf back, so the + // config the connect path hands the client never changes. Without the + // restore, the served projection is NOT a working client config (measured: + // construction throws `Failed to parse KMS provider` for aws/azure/gcp) — + // which is exactly why the structural mirror must cover these rows. + const CSFLE_MONGO: StoredDatasource = { + name: 'csfle_mongo', + driver: 'mongodb', + origin: 'runtime', + config: { + url: 'mongodb://app@mongo.internal:27017/events', + options: { + autoEncryption: { + keyVaultNamespace: 'encryption.__keyVault', + kmsProviders: { + aws: { accessKeyId: 'AKIAFAKEFAKEFAKEFAKE', secretAccessKey: 'AWS-SECRET', sessionToken: 'AWS-SESSION' }, + azure: { tenantId: 'tenant-id', clientId: 'client-id', clientSecret: 'AZURE-SECRET' }, + gcp: { email: 'svc@example.iam.gserviceaccount.com', privateKey: 'R0NQLUtFWQ==' }, + local: { key: 'TE9DQUwtS0VZ' }, + }, + }, + }, + }, + }; + const { service, records } = makeService([CSFLE_MONGO]); + const read = await service.getDatasource('csfle_mongo'); + // Served: key material withheld, and said so; identity halves intact. + expect(JSON.stringify(read!.config)).not.toMatch(/AWS-SECRET|AWS-SESSION|AZURE-SECRET|R0NQLUtFWQ|TE9DQUwtS0VZ/); + expect(read!.redactedConfigKeys).toEqual([ + 'options.autoEncryption.kmsProviders.aws.secretAccessKey', + 'options.autoEncryption.kmsProviders.aws.sessionToken', + 'options.autoEncryption.kmsProviders.azure.clientSecret', + 'options.autoEncryption.kmsProviders.gcp.privateKey', + 'options.autoEncryption.kmsProviders.local.key', + ]); + // Untouched Save: the stored row comes back byte-identical. + await service.updateDatasource('csfle_mongo', { config: read!.config, label: 'Renamed' }); + expect(records[0].label).toBe('Renamed'); + expect(records[0].config).toEqual(CSFLE_MONGO.config); + }); + it('an author who deletes the `auth` block WINS — a removed container is never re-grafted', async () => { const { service, records } = makeService([LEGACY_MONGO]); const read = await service.getDatasource('legacy_mongo'); diff --git a/packages/spec/src/data/datasource-credential-redaction.test.ts b/packages/spec/src/data/datasource-credential-redaction.test.ts index aa33309da7..0c040c8467 100644 --- a/packages/spec/src/data/datasource-credential-redaction.test.ts +++ b/packages/spec/src/data/datasource-credential-redaction.test.ts @@ -341,6 +341,80 @@ describe('passthrough secret redaction (#9040) — the nested spellings the key- ]); }); + it('drops CSFLE `kmsProviders` key material in every family, keeps the identity halves', () => { + // Measured against mongodb@7.5.0 BOTH ways the optional + // `mongodb-client-encryption` dependency can go: with it installed (7.2.1, + // inside the pin's ^7.2.0 optional-peer range) an instrumented constructor + // reads every one of these leaves (BSON-serialized into the native + // MongoCrypt); without it the same construction throws + // MongoMissingDependencyError before reading any of them. Either way a + // stored copy is decryption-capable material served in cleartext — the + // AWS_SESSION_TOKEN posture. + const { config, redactedKeys } = redactDatasourceConfig('mongodb', { + url: 'mongodb://app@mongo.internal:27017/events', + options: { + replicaSet: 'rs0', + autoEncryption: { + keyVaultNamespace: 'encryption.__keyVault', + kmsProviders: { + aws: { accessKeyId: 'AKIAFAKEFAKEFAKEFAKE', secretAccessKey: 'AWS-SECRET', sessionToken: 'AWS-SESSION' }, + azure: { tenantId: 'tenant-id', clientId: 'client-id', clientSecret: 'AZURE-SECRET' }, + gcp: { email: 'svc@example.iam.gserviceaccount.com', privateKey: 'R0NQLUtFWQ==' }, + local: { key: 'TE9DQUwtS0VZ' }, + }, + }, + }, + }); + expect(config).toEqual({ + url: 'mongodb://app@mongo.internal:27017/events', + options: { + replicaSet: 'rs0', + autoEncryption: { + keyVaultNamespace: 'encryption.__keyVault', + kmsProviders: { + // The identity halves the client also reads are NOT credential + // material (#8876's asymmetry) and stay served. + aws: { accessKeyId: 'AKIAFAKEFAKEFAKEFAKE' }, + azure: { tenantId: 'tenant-id', clientId: 'client-id' }, + gcp: { email: 'svc@example.iam.gserviceaccount.com' }, + local: {}, + }, + }, + }, + }); + expect(redactedKeys).toEqual([ + 'options.autoEncryption.kmsProviders.aws.secretAccessKey', + 'options.autoEncryption.kmsProviders.aws.sessionToken', + 'options.autoEncryption.kmsProviders.azure.clientSecret', + 'options.autoEncryption.kmsProviders.gcp.privateKey', + 'options.autoEncryption.kmsProviders.local.key', + ]); + }); + + it('unmeasured `autoEncryption` neighbours stay served — the table is measurement-only', () => { + // Negative control for the CSFLE rows: positions the measurement did NOT + // establish as secret material (`kmip.endpoint`, `keyVaultNamespace`, + // `schemaMap`, `bypassAutoEncryption`) are not on the table, mirror no + // credential spelling, and come back byte-identical — the discipline that + // entries land only with a measurement quoted, never by name-shape. + const table = passthroughSecretPaths('mongodb').map((p) => p.join('.')); + expect(table).not.toContain('options.autoEncryption.kmsProviders.kmip.endpoint'); + expect(table).not.toContain('options.autoEncryption.keyVaultNamespace'); + const stored = { + options: { + autoEncryption: { + keyVaultNamespace: 'encryption.__keyVault', + bypassAutoEncryption: false, + schemaMap: { 'appdb.people': { bsonType: 'object' } }, + kmsProviders: { kmip: { endpoint: 'kmip.internal:5696' } }, + }, + }, + }; + const { config, redactedKeys } = redactDatasourceConfig('mongodb', stored); + expect(config).toEqual(stored); + expect(redactedKeys).toEqual([]); + }); + it('a config without the passthrough — or with a malformed one — is untouched', () => { expect(redactDatasourceConfig('mongodb', { database: 'events' }).redactedKeys).toEqual([]); // Off-shape walks fall off silently rather than throwing on a stored row. diff --git a/packages/spec/src/data/datasource-credential-redaction.ts b/packages/spec/src/data/datasource-credential-redaction.ts index c9d1965c3d..553deaf554 100644 --- a/packages/spec/src/data/datasource-credential-redaction.ts +++ b/packages/spec/src/data/datasource-credential-redaction.ts @@ -155,6 +155,26 @@ const STILL_WRITABLE_CREDENTIAL_KEYS: Record = { * Either way a stored copy is a secret served in cleartext, and serving it * back is a leak under any boundary (the same asymmetry with the write * door this module already documents for the inline keys). + * - `options.autoEncryption.kmsProviders.{aws.secretAccessKey, + * aws.sessionToken, azure.clientSecret, gcp.privateKey, local.key}` — + * CSFLE KMS key material, measured both ways the client's OPTIONAL + * `mongodb-client-encryption` dependency can go (the answer differs, so + * both were run). With it installed (7.2.1, inside the pin's `^7.2.0` + * optional-peer range): an instrumented `new MongoClient(url, + * { …, ...options })` READS every one of these leaves at construction — + * the kmsProviders record is BSON-serialized into the native MongoCrypt — + * and `connect()` proceeds into live CSFLE machinery. Without it: the same + * construction throws `MongoMissingDependencyError` before reading any of + * them. Either way a stored copy is decryption-capable material served in + * cleartext (the AWS_SESSION_TOKEN posture: a loud client failure does not + * make serving the secret back acceptable). Still WRITABLE — the binder's + * one slot is the login password, the proxyPassword posture — but never + * SERVED. The same families' identity halves (`aws.accessKeyId`, + * `azure.tenantId` / `clientId`, `gcp.email`) are read by the client too + * but are not credential material (#8876's asymmetry), and the unmeasured + * neighbours (`kmip.endpoint`, `keyVaultNamespace`, `schemaMap`) mirror no + * credential spelling — deliberately not here: entries land on this table + * with a measurement quoted, never by name-shape. * * Keyed by CANONICAL driver id and looked up through {@link resolveDriverId}, * so a stored legacy `driver: 'mongo'` row is scrubbed identically to @@ -168,6 +188,11 @@ const PASSTHROUGH_SECRET_PATHS: Readonly