From 0f22f4a174fd1e526159f41b678badeb4c85b51e Mon Sep 17 00:00:00 2001 From: Robert Yokota Date: Thu, 9 Jul 2026 11:18:50 -0700 Subject: [PATCH 1/4] DGS-24721 Add support for saving Azure key version with DEK --- .../rules/encryption/azurekms/azure-client.ts | 107 +++++++++++++++-- .../rules/encryption/azurekms/azure-driver.ts | 99 ++++++++++++++- .../rules/encryption/encrypt-executor.ts | 20 +++- .../encryption/azurekms/azure-client.spec.ts | 113 ++++++++++++++++++ .../encryption/azurekms/azure-driver.spec.ts | 101 ++++++++++++++++ 5 files changed, 425 insertions(+), 15 deletions(-) create mode 100644 schemaregistry/test/rules/encryption/azurekms/azure-client.spec.ts create mode 100644 schemaregistry/test/rules/encryption/azurekms/azure-driver.spec.ts diff --git a/schemaregistry/rules/encryption/azurekms/azure-client.ts b/schemaregistry/rules/encryption/azurekms/azure-client.ts index 2898721b7..2bc813c78 100644 --- a/schemaregistry/rules/encryption/azurekms/azure-client.ts +++ b/schemaregistry/rules/encryption/azurekms/azure-client.ts @@ -2,34 +2,125 @@ import {KmsClient} from "../kms-registry"; import {AzureKmsDriver} from "./azure-driver"; import {TokenCredential} from "@azure/identity"; import {CryptographyClient, EncryptionAlgorithm} from "@azure/keyvault-keys"; +import {SecurityException} from "../tink/exception/security_exception"; +const PREFIX = Buffer.from('azure:v1:', 'ascii') +const VERSION_LENGTH = 32 +const HEADER_LENGTH = PREFIX.length + VERSION_LENGTH + 1 // +1 for ':' +const HEX_VERSION_RE = /^[0-9a-fA-F]+$/ + +function isValidVersion(value: string | null | undefined): boolean { + return value != null && value.length === VERSION_LENGTH && HEX_VERSION_RE.test(value) +} + +/** + * Returns the embedded version if ciphertext carries the azure:v1: prefix (see class doc), or + * null if it does not (e.g. a legacy DEK wrapped before ENCRYPT_AZURE_KEY_VERSION_SAVE was + * enabled on its KEK, or the toggle is not set). Returning null rather than throwing is + * deliberate: the toggle can be flipped on/off over a KEK's lifetime, and old, un-prefixed + * ciphertext must remain decryptable. + */ +function extractVersion(ciphertext: Buffer): string | null { + if ( + ciphertext.length < HEADER_LENGTH || + !ciphertext.subarray(0, PREFIX.length).equals(PREFIX) || + ciphertext[HEADER_LENGTH - 1] !== 0x3a // ':' + ) { + return null + } + return ciphertext.subarray(PREFIX.length, PREFIX.length + VERSION_LENGTH).toString('ascii') +} + +/** + * Basic Azure client for encryption/decryption. + * + * Unlike AWS KMS and GCP KMS, Azure Key Vault addresses wrap/unwrap by an explicit key version + * and does not embed that version in the ciphertext it returns. When + * AzureKmsDriver.ENCRYPT_AZURE_KEY_VERSION_SAVE is enabled, encrypt() makes its output + * self-describing by prepending the exact version that produced it: + * `azure:v1:` + 32-character key version + `:` + raw ciphertext bytes. + * + * decrypt() always checks for this prefix regardless of the current toggle value, since a DEK + * wrapped while the toggle was on must remain decryptable even after it is turned back off. + */ export class AzureKmsClient implements KmsClient { private static ALGORITHM: EncryptionAlgorithm = 'RSA-OAEP-256' - private kmsClient: CryptographyClient - private keyUri: string - private keyId: string + private readonly defaultClient: CryptographyClient + private readonly keyUri: string + private readonly keyId: string + private readonly credentials: TokenCredential + private readonly config: Map - constructor(keyUri: string, creds: TokenCredential) { + constructor(keyUri: string, creds: TokenCredential, config: Map = new Map()) { if (!keyUri.startsWith(AzureKmsDriver.PREFIX)) { throw new Error(`key uri must start with ${AzureKmsDriver.PREFIX}`) } this.keyUri = keyUri this.keyId = keyUri.substring(AzureKmsDriver.PREFIX.length) - this.kmsClient = new CryptographyClient(this.keyId, creds) + this.credentials = creds + this.config = config + // Cheap to build eagerly: the constructor does not itself make a network call (the Azure SDK + // resolves lazily on the first actual encrypt/decrypt call), and it is used directly whenever + // the toggle is off, and as decrypt()'s fallback for legacy ciphertext with no embedded + // version. + this.defaultClient = new CryptographyClient(this.keyId, creds) } supported(keyUri: string): boolean { return this.keyUri === keyUri } + private saveVersion(): boolean { + const value = this.config.get(AzureKmsDriver.ENCRYPT_AZURE_KEY_VERSION_SAVE) + return value != null && value.toLowerCase() === 'true' + } + + /** + * Builds a CryptographyClient for an explicit version. Used both by encrypt() (once it has + * resolved the current version) and decrypt() (to target whichever version is embedded in + * already-wrapped ciphertext) -- there is only one place that knows how to turn a version into + * a client. + */ + private clientForVersion(version: string): CryptographyClient { + const versionedKeyUri = AzureKmsDriver.withVersion(this.keyId, version) + return new CryptographyClient(versionedKeyUri, this.credentials) + } + async encrypt(plaintext: Buffer): Promise { - const result = await this.kmsClient.encrypt(AzureKmsClient.ALGORITHM, plaintext) - return Buffer.from(result.result) + if (!this.saveVersion()) { + const result = await this.defaultClient.encrypt(AzureKmsClient.ALGORITHM, plaintext) + return Buffer.from(result.result) + } + const resolvedKeyUri = await AzureKmsDriver.getVersionedKeyId(this.config, this.keyId) + const version = resolvedKeyUri.substring(resolvedKeyUri.lastIndexOf('/') + 1) + if (!isValidVersion(version)) { + // Mirrors decrypt()'s own validation: a DEK this method wraps must always be one this same + // class can later unwrap. + throw new SecurityException( + `kms key version '${version}' must be a ${VERSION_LENGTH}-character hex string; cannot ` + + 'be embedded in a fixed-width azure:v1: prefix') + } + const client = this.clientForVersion(version) + const result = await client.encrypt(AzureKmsClient.ALGORITHM, plaintext) + return Buffer.concat([PREFIX, Buffer.from(version, 'ascii'), Buffer.from(':'), Buffer.from(result.result)]) } async decrypt(ciphertext: Buffer): Promise { - const result = await this.kmsClient.decrypt(AzureKmsClient.ALGORITHM, ciphertext) + let client = this.defaultClient + let wrapped = ciphertext + const version = extractVersion(ciphertext) + if (version != null) { + if (!isValidVersion(version)) { + // Encrypted key material is unauthenticated at this layer, so a corrupted or tampered + // value could otherwise smuggle arbitrary characters (e.g. '/') into the key identifier + // URL built from it below. + throw new SecurityException(`ciphertext carries an invalid azure:v1: key version: '${version}'`) + } + client = this.clientForVersion(version) + wrapped = ciphertext.subarray(HEADER_LENGTH) + } + const result = await client.decrypt(AzureKmsClient.ALGORITHM, wrapped) return Buffer.from(result.result) } } diff --git a/schemaregistry/rules/encryption/azurekms/azure-driver.ts b/schemaregistry/rules/encryption/azurekms/azure-driver.ts index 01c01cd15..715e7cbf8 100644 --- a/schemaregistry/rules/encryption/azurekms/azure-driver.ts +++ b/schemaregistry/rules/encryption/azurekms/azure-driver.ts @@ -1,7 +1,15 @@ import {KmsClient, KmsDriver, registerKmsDriver} from "../kms-registry"; import {ClientSecretCredential, DefaultAzureCredential, TokenCredential} from '@azure/identity' +import {KeyClient} from '@azure/keyvault-keys' +import {SecurityException} from "../tink/exception/security_exception"; import {AzureKmsClient} from "./azure-client"; +interface AzureKeyId { + vaultUrl: string + name: string + version?: string +} + export class AzureKmsDriver implements KmsDriver { static PREFIX = 'azure-kms://' @@ -9,6 +17,14 @@ export class AzureKmsDriver implements KmsDriver { static CLIENT_ID = 'client.id' static CLIENT_SECRET = 'client.secret' + /** + * Enables making a DEK's encrypted key material self-describing with respect to which exact + * Azure Key Vault key version wrapped it (see AzureKmsClient), matching the same + * self-description property AWS KMS and GCP KMS ciphertext already provide natively. Set as a + * kek kmsProps entry. + */ + static ENCRYPT_AZURE_KEY_VERSION_SAVE = 'encrypt.azure.key.version.save' + /** * Register the Azure KMS driver with the KMS registry. */ @@ -22,15 +38,88 @@ export class AzureKmsDriver implements KmsDriver { newKmsClient(config: Map, keyUrl?: string): KmsClient { const uriPrefix = keyUrl != null ? keyUrl : AzureKmsDriver.PREFIX + const creds = AzureKmsDriver.getCredentials(config) + return new AzureKmsClient(uriPrefix, creds, config) + } + + static getCredentials(config: Map): TokenCredential { const tenantId = config.get(AzureKmsDriver.TENANT_ID) const clientId = config.get(AzureKmsDriver.CLIENT_ID) const clientSecret = config.get(AzureKmsDriver.CLIENT_SECRET) - let creds: TokenCredential if (tenantId != null && clientId != null && clientSecret != null) { - creds = new ClientSecretCredential(tenantId, clientId, clientSecret) - } else { - creds = new DefaultAzureCredential() + return new ClientSecretCredential(tenantId, clientId, clientSecret) + } + return new DefaultAzureCredential() + } + + /** + * Returns true if kmsKeyId has no explicit version segment. Used to warn when + * ENCRYPT_AZURE_KEY_VERSION_SAVE is not enabled for a versionless key, without performing any + * actual resolution (no KeyClient call). + */ + static isVersionless(kmsKeyId: string): boolean { + return AzureKmsDriver.parse(kmsKeyId).version == null + } + + /** + * Combines kmsKeyId (versionless or versioned; only the vault and key name are used) with an + * explicit version, returning the full versioned key identifier. Used to reconstruct a target + * for a version extracted from an already-wrapped DEK, which may differ from whatever + * getVersionedKeyId currently resolves to (e.g. after a rotation). + */ + static withVersion(kmsKeyId: string, version: string): string { + const parsed = AzureKmsDriver.parse(kmsKeyId) + return `${parsed.vaultUrl}/keys/${parsed.name}/${version}` + } + + /** + * Resolves a possibly-versionless Azure Key Vault key identifier (e.g. + * "https://vault.vault.azure.net/keys/name") into the concrete, currently-enabled version (e.g. + * "https://vault.vault.azure.net/keys/name/"). If kmsKeyId already includes a version + * segment, it is returned unchanged and no call is made. + * + * This exists because, unlike AWS KMS and GCP KMS, Azure Key Vault's wrap/unwrap operations + * address an explicit key version and do not embed that version in the returned ciphertext, so + * a caller that only ever uses a versionless reference has no way to know which version + * encrypted a given DEK once the key has been rotated. + */ + static async getVersionedKeyId(config: Map, kmsKeyId: string): Promise { + const parsed = AzureKmsDriver.parse(kmsKeyId) + if (parsed.version != null) { + // Already versioned; respect the explicitly pinned config as-is. + return kmsKeyId + } + const client = new KeyClient(parsed.vaultUrl, AzureKmsDriver.getCredentials(config)) + let key + try { + key = await client.getKey(parsed.name) + } catch (e) { + throw new SecurityException( + `Failed to resolve Azure Key Vault key id for key name '${parsed.name}' in vault ${parsed.vaultUrl}: ${e}`) + } + if (key == null || key.id == null) { + throw new SecurityException( + `Failed to resolve Azure Key Vault key id for key name '${parsed.name}' in vault ${parsed.vaultUrl}`) + } + const resolvedId = key.id + if (AzureKmsDriver.parse(resolvedId).version == null) { + throw new SecurityException(`Resolved Azure Key Vault key id is missing a version segment: ${resolvedId}`) + } + return resolvedId + } + + private static parse(kmsKeyId: string): AzureKeyId { + let url: URL + try { + url = new URL(kmsKeyId) + } catch { + throw new SecurityException(`Invalid Azure Key Vault key id: ${kmsKeyId}`) + } + const segments = url.pathname.split('/').filter(s => s.length > 0) + if (segments.length < 2 || segments.length > 3 || segments[0] !== 'keys') { + throw new SecurityException(`Invalid Azure Key Vault key id: ${kmsKeyId}`) } - return new AzureKmsClient(uriPrefix, creds) + const vaultUrl = `${url.protocol}//${url.host}` + return {vaultUrl, name: segments[1], version: segments.length === 3 ? segments[2] : undefined} } } diff --git a/schemaregistry/rules/encryption/encrypt-executor.ts b/schemaregistry/rules/encryption/encrypt-executor.ts index aca488df0..3ac06f17a 100644 --- a/schemaregistry/rules/encryption/encrypt-executor.ts +++ b/schemaregistry/rules/encryption/encrypt-executor.ts @@ -671,14 +671,29 @@ export class KmsClientWrapper implements KmsClient { return kmsKeyIds } + /** + * Merges the kek's kmsProps (e.g. encrypt.azure.key.version.save) into a copy of the + * executor-level config, so KMS-specific per-kek settings reach newKmsClient/the KmsClient. + */ + private getAeadConfig(): Map { + const aeadConfig = new Map(this.config) + if (this.kek.kmsProps != null) { + for (const [key, value] of Object.entries(this.kek.kmsProps)) { + aeadConfig.set(key, value) + } + } + return aeadConfig + } + supported(keyUri: string): boolean { return this.kekId === keyUri } async encrypt(rawKey: Buffer): Promise { + const aeadConfig = this.getAeadConfig() for (let i = 0; i < this.kmsKeyIds.length; i++) { try { - let kmsClient = getKmsClient(this.config, this.kek.kmsType!, this.kmsKeyIds[i]) + let kmsClient = getKmsClient(aeadConfig, this.kek.kmsType!, this.kmsKeyIds[i]) return await kmsClient.encrypt(rawKey) } catch (e) { if (i === this.kmsKeyIds.length - 1) { @@ -690,9 +705,10 @@ export class KmsClientWrapper implements KmsClient { } async decrypt(encryptedKey: Buffer): Promise { + const aeadConfig = this.getAeadConfig() for (let i = 0; i < this.kmsKeyIds.length; i++) { try { - let kmsClient = getKmsClient(this.config, this.kek.kmsType!, this.kmsKeyIds[i]) + let kmsClient = getKmsClient(aeadConfig, this.kek.kmsType!, this.kmsKeyIds[i]) return await kmsClient.decrypt(encryptedKey) } catch (e) { if (i === this.kmsKeyIds.length - 1) { diff --git a/schemaregistry/test/rules/encryption/azurekms/azure-client.spec.ts b/schemaregistry/test/rules/encryption/azurekms/azure-client.spec.ts new file mode 100644 index 000000000..f19205909 --- /dev/null +++ b/schemaregistry/test/rules/encryption/azurekms/azure-client.spec.ts @@ -0,0 +1,113 @@ +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { AzureKmsClient } from '../../../../rules/encryption/azurekms/azure-client'; +import { AzureKmsDriver } from '../../../../rules/encryption/azurekms/azure-driver'; +import { SecurityException } from '../../../../rules/encryption/tink/exception/security_exception'; +import { CryptographyClient, KeyClient } from '@azure/keyvault-keys'; +import { TokenCredential } from '@azure/identity'; + +jest.mock('@azure/keyvault-keys'); +jest.mock('@azure/identity'); + +const KEY_URI = 'azure-kms://https://yokota1.vault.azure.net/keys/key1'; +const VERSION_A = 'a'.repeat(32); +const VERSION_B = 'b'.repeat(32); +const CREDS = {} as TokenCredential; + +const MockedCryptographyClient = CryptographyClient as jest.MockedClass; +const MockedKeyClient = KeyClient as jest.MockedClass; + +describe('AzureKmsClient', () => { + beforeEach(() => { + MockedCryptographyClient.mockClear(); + MockedKeyClient.mockClear(); + }); + + it('encrypt returns raw ciphertext when the toggle is off', async () => { + MockedCryptographyClient.prototype.encrypt = jest.fn(async () => ({ result: Buffer.from('raw-ciphertext') } as any)); + + const client = new AzureKmsClient(KEY_URI, CREDS, new Map()); + const result = await client.encrypt(Buffer.from('plaintext')); + + expect(result).toEqual(Buffer.from('raw-ciphertext')); + }); + + it('encrypt prefixes with the resolved version without double-encoding when the toggle is on', async () => { + MockedKeyClient.prototype.getKey = jest.fn(async () => ( + { name: 'key1', id: `https://yokota1.vault.azure.net/keys/key1/${VERSION_A}` } as any + )); + MockedCryptographyClient.prototype.encrypt = jest.fn(async () => ({ result: Buffer.from('wrapped-bytes') } as any)); + + const config = new Map([[AzureKmsDriver.ENCRYPT_AZURE_KEY_VERSION_SAVE, 'true']]); + const client = new AzureKmsClient(KEY_URI, CREDS, config); + const result = await client.encrypt(Buffer.from('plaintext')); + + expect(result).toEqual(Buffer.concat([ + Buffer.from('azure:v1:', 'ascii'), Buffer.from(VERSION_A, 'ascii'), Buffer.from(':'), Buffer.from('wrapped-bytes'), + ])); + }); + + it('decrypt uses the embedded version to build a versioned client', async () => { + MockedCryptographyClient.prototype.decrypt = jest.fn(async () => ({ result: Buffer.from('correct-plaintext') } as any)); + + const client = new AzureKmsClient(KEY_URI, CREDS, new Map()); + const ciphertext = Buffer.concat([ + Buffer.from('azure:v1:', 'ascii'), Buffer.from(VERSION_A, 'ascii'), Buffer.from(':'), Buffer.from('wrapped-bytes'), + ]); + + const result = await client.decrypt(ciphertext); + + expect(result).toEqual(Buffer.from('correct-plaintext')); + // The default client (built from the versionless key id) plus one more for the embedded + // version. + expect(MockedCryptographyClient).toHaveBeenCalledTimes(2); + expect(MockedCryptographyClient.mock.calls[1][0]).toBe(`https://yokota1.vault.azure.net/keys/key1/${VERSION_A}`); + }); + + it('decrypt falls back to the default client for legacy unprefixed ciphertext', async () => { + MockedCryptographyClient.prototype.decrypt = jest.fn(async () => ({ result: Buffer.from('legacy-plaintext') } as any)); + + const client = new AzureKmsClient(KEY_URI, CREDS, new Map()); + const result = await client.decrypt(Buffer.from('legacy-unprefixed-ciphertext')); + + expect(result).toEqual(Buffer.from('legacy-plaintext')); + // Only the default client was ever constructed -- no version-specific client was needed. + expect(MockedCryptographyClient).toHaveBeenCalledTimes(1); + }); + + it('decrypt remains possible after the toggle is turned back off', async () => { + // A DEK wrapped while ENCRYPT_AZURE_KEY_VERSION_SAVE was on must stay decryptable even once + // the toggle is turned back off: decrypt() must not depend on the toggle at all. + MockedCryptographyClient.prototype.decrypt = jest.fn(async () => ({ result: Buffer.from('still-decryptable') } as any)); + + const config = new Map([[AzureKmsDriver.ENCRYPT_AZURE_KEY_VERSION_SAVE, 'false']]); + const client = new AzureKmsClient(KEY_URI, CREDS, config); + const ciphertext = Buffer.concat([ + Buffer.from('azure:v1:', 'ascii'), Buffer.from(VERSION_B, 'ascii'), Buffer.from(':'), Buffer.from('wrapped-bytes'), + ]); + + const result = await client.decrypt(ciphertext); + + expect(result).toEqual(Buffer.from('still-decryptable')); + }); + + it('decrypt throws for a non-hex embedded version', async () => { + const client = new AzureKmsClient(KEY_URI, CREDS, new Map()); + const nonHexVersion = 'g'.repeat(32); + const ciphertext = Buffer.concat([ + Buffer.from('azure:v1:', 'ascii'), Buffer.from(nonHexVersion, 'ascii'), Buffer.from(':'), Buffer.from('wrapped-bytes'), + ]); + + await expect(client.decrypt(ciphertext)).rejects.toThrow(SecurityException); + }); + + it('encrypt throws when the resolved version is not fixed-width hex', async () => { + MockedKeyClient.prototype.getKey = jest.fn(async () => ( + { name: 'key1', id: 'https://yokota1.vault.azure.net/keys/key1/not-32-chars' } as any + )); + + const config = new Map([[AzureKmsDriver.ENCRYPT_AZURE_KEY_VERSION_SAVE, 'true']]); + const client = new AzureKmsClient(KEY_URI, CREDS, config); + + await expect(client.encrypt(Buffer.from('plaintext'))).rejects.toThrow(SecurityException); + }); +}); diff --git a/schemaregistry/test/rules/encryption/azurekms/azure-driver.spec.ts b/schemaregistry/test/rules/encryption/azurekms/azure-driver.spec.ts new file mode 100644 index 000000000..08d920027 --- /dev/null +++ b/schemaregistry/test/rules/encryption/azurekms/azure-driver.spec.ts @@ -0,0 +1,101 @@ +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { AzureKmsDriver } from '../../../../rules/encryption/azurekms/azure-driver'; +import { SecurityException } from '../../../../rules/encryption/tink/exception/security_exception'; +import { KeyClient } from '@azure/keyvault-keys'; + +jest.mock('@azure/keyvault-keys'); +jest.mock('@azure/identity'); + +const VERSION_A = 'a'.repeat(32); +const VERSIONLESS_KEY_ID = 'https://yokota1.vault.azure.net/keys/key1'; +const VERSIONED_KEY_ID = `${VERSIONLESS_KEY_ID}/${VERSION_A}`; + +const MockedKeyClient = KeyClient as jest.MockedClass; + +describe('AzureKmsDriver', () => { + beforeEach(() => { + MockedKeyClient.mockClear(); + }); + + it('isVersionless is true for a versionless id', () => { + expect(AzureKmsDriver.isVersionless(VERSIONLESS_KEY_ID)).toBe(true); + }); + + it('isVersionless is false for a versioned id', () => { + expect(AzureKmsDriver.isVersionless(VERSIONED_KEY_ID)).toBe(false); + }); + + it('isVersionless throws for a malformed id', () => { + expect(() => AzureKmsDriver.isVersionless('https://yokota1.vault.azure.net/notkeys/key1')) + .toThrow(SecurityException); + }); + + it('withVersion combines a versionless id with an explicit version', () => { + expect(AzureKmsDriver.withVersion(VERSIONLESS_KEY_ID, VERSION_A)).toBe(VERSIONED_KEY_ID); + }); + + it('withVersion ignores any existing version segment', () => { + // Only the vault and key name are used; any existing version is discarded in favor of the + // explicit version argument. + const otherVersion = 'b'.repeat(32); + expect(AzureKmsDriver.withVersion(VERSIONED_KEY_ID, otherVersion)) + .toBe(`${VERSIONLESS_KEY_ID}/${otherVersion}`); + }); + + it('getVersionedKeyId returns unchanged when already versioned', async () => { + const result = await AzureKmsDriver.getVersionedKeyId(new Map(), VERSIONED_KEY_ID); + + expect(result).toBe(VERSIONED_KEY_ID); + expect(MockedKeyClient).not.toHaveBeenCalled(); + }); + + it('getVersionedKeyId resolves a versionless id', async () => { + MockedKeyClient.prototype.getKey = jest.fn(async () => ({ name: 'key1', id: VERSIONED_KEY_ID } as any)); + + const result = await AzureKmsDriver.getVersionedKeyId(new Map(), VERSIONLESS_KEY_ID); + + expect(result).toBe(VERSIONED_KEY_ID); + expect(MockedKeyClient.prototype.getKey).toHaveBeenCalledWith('key1'); + }); + + it('getVersionedKeyId throws for a malformed key id', async () => { + await expect(AzureKmsDriver.getVersionedKeyId(new Map(), 'https://yokota1.vault.azure.net/notkeys/key1')) + .rejects.toThrow(SecurityException); + }); + + it('getVersionedKeyId throws for an invalid uri', async () => { + await expect(AzureKmsDriver.getVersionedKeyId(new Map(), '::not a uri::')) + .rejects.toThrow(SecurityException); + }); + + it('getVersionedKeyId wraps an exception from the resolver', async () => { + MockedKeyClient.prototype.getKey = jest.fn(async () => { + throw new Error('simulated RestError'); + }); + + await expect(AzureKmsDriver.getVersionedKeyId(new Map(), VERSIONLESS_KEY_ID)) + .rejects.toThrow(SecurityException); + }); + + it('getVersionedKeyId throws when the resolver returns null', async () => { + MockedKeyClient.prototype.getKey = jest.fn(async () => null as any); + + await expect(AzureKmsDriver.getVersionedKeyId(new Map(), VERSIONLESS_KEY_ID)) + .rejects.toThrow(SecurityException); + }); + + it('getVersionedKeyId throws when the resolved key id has no id', async () => { + MockedKeyClient.prototype.getKey = jest.fn(async () => ({ name: 'key1', id: undefined } as any)); + + await expect(AzureKmsDriver.getVersionedKeyId(new Map(), VERSIONLESS_KEY_ID)) + .rejects.toThrow(SecurityException); + }); + + it('getVersionedKeyId throws when the resolved key id is still versionless', async () => { + // Resolver misconfiguration: returns the same versionless id it was asked to resolve. + MockedKeyClient.prototype.getKey = jest.fn(async () => ({ name: 'key1', id: VERSIONLESS_KEY_ID } as any)); + + await expect(AzureKmsDriver.getVersionedKeyId(new Map(), VERSIONLESS_KEY_ID)) + .rejects.toThrow(SecurityException); + }); +}); From 468da53de841e4c4b5520e97a301a52ac52a0371 Mon Sep 17 00:00:00 2001 From: Robert Yokota Date: Thu, 9 Jul 2026 14:36:45 -0700 Subject: [PATCH 2/4] Minor fix --- schemaregistry/rules/encryption/azurekms/azure-driver.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/schemaregistry/rules/encryption/azurekms/azure-driver.ts b/schemaregistry/rules/encryption/azurekms/azure-driver.ts index 715e7cbf8..2164e5a69 100644 --- a/schemaregistry/rules/encryption/azurekms/azure-driver.ts +++ b/schemaregistry/rules/encryption/azurekms/azure-driver.ts @@ -94,8 +94,9 @@ export class AzureKmsDriver implements KmsDriver { try { key = await client.getKey(parsed.name) } catch (e) { + const details = e instanceof Error ? e.message : String(e) throw new SecurityException( - `Failed to resolve Azure Key Vault key id for key name '${parsed.name}' in vault ${parsed.vaultUrl}: ${e}`) + `Failed to resolve Azure Key Vault key id for key name '${parsed.name}' in vault ${parsed.vaultUrl}: ${details}`) } if (key == null || key.id == null) { throw new SecurityException( From dddc868e03fb7c3cfe54d492c6dfc9f809c8d34f Mon Sep 17 00:00:00 2001 From: Robert Yokota Date: Thu, 9 Jul 2026 17:30:40 -0700 Subject: [PATCH 3/4] Minor fix --- schemaregistry/rules/encryption/azurekms/azure-driver.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/schemaregistry/rules/encryption/azurekms/azure-driver.ts b/schemaregistry/rules/encryption/azurekms/azure-driver.ts index 2164e5a69..19356672e 100644 --- a/schemaregistry/rules/encryption/azurekms/azure-driver.ts +++ b/schemaregistry/rules/encryption/azurekms/azure-driver.ts @@ -74,9 +74,9 @@ export class AzureKmsDriver implements KmsDriver { /** * Resolves a possibly-versionless Azure Key Vault key identifier (e.g. - * "https://vault.vault.azure.net/keys/name") into the concrete, currently-enabled version (e.g. - * "https://vault.vault.azure.net/keys/name/"). If kmsKeyId already includes a version - * segment, it is returned unchanged and no call is made. + * "https://vault.vault.azure.net/keys/name") into the concrete latest key version returned by + * Key Vault (e.g. "https://vault.vault.azure.net/keys/name/"). If kmsKeyId already + * includes a version segment, it is returned unchanged and no call is made. * * This exists because, unlike AWS KMS and GCP KMS, Azure Key Vault's wrap/unwrap operations * address an explicit key version and do not embed that version in the returned ciphertext, so From a54f5f4e72717e352d4c44e5c2ff003c2814bb6c Mon Sep 17 00:00:00 2001 From: Robert Yokota Date: Fri, 10 Jul 2026 09:13:50 -0700 Subject: [PATCH 4/4] Update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index db43aef1f..6e4592f3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Enhancements 1. Support generating a JSON Schema title from a JSON payload (#505) +2. Add support for saving Azure key version with DEK (#507) # confluent-kafka-javascript 1.10.0