Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 99 additions & 8 deletions schemaregistry/rules/encryption/azurekms/azure-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,34 +2,125 @@
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)

Check warning on line 13 in schemaregistry/rules/encryption/azurekms/azure-client.ts

View check run for this annotation

SonarQube-Confluent / SonarQube Code Analysis

Prefer using an optional chain expression instead, as it's more concise and easier to read.

[S6582] Optional chaining should be preferred See more on https://sonarqube.confluent.io/project/issues?id=confluent-kafka-javascript&pullRequest=507&issues=f2e7f398-88d2-40d1-b121-2032bfbe2f61&open=f2e7f398-88d2-40d1-b121-2032bfbe2f61
}

/**
* 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<string, string>

constructor(keyUri: string, creds: TokenCredential) {
constructor(keyUri: string, creds: TokenCredential, config: Map<string, string> = 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'

Check warning on line 76 in schemaregistry/rules/encryption/azurekms/azure-client.ts

View check run for this annotation

SonarQube-Confluent / SonarQube Code Analysis

Prefer using an optional chain expression instead, as it's more concise and easier to read.

[S6582] Optional chaining should be preferred See more on https://sonarqube.confluent.io/project/issues?id=confluent-kafka-javascript&pullRequest=507&issues=801b009f-5671-4222-be02-06a5653e6de0&open=801b009f-5671-4222-be02-06a5653e6de0
}

/**
* 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<Buffer> {
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<Buffer> {
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)
}
}
99 changes: 94 additions & 5 deletions schemaregistry/rules/encryption/azurekms/azure-driver.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,30 @@
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://'
static TENANT_ID = 'tenant.id'
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'

Check warning on line 26 in schemaregistry/rules/encryption/azurekms/azure-driver.ts

View check run for this annotation

SonarQube-Confluent / SonarQube Code Analysis

Make this public static property readonly.

[S1444] Public "static" fields should be read-only See more on https://sonarqube.confluent.io/project/issues?id=confluent-kafka-javascript&pullRequest=507&issues=c1438b2b-cc20-4adc-bd63-ce02b6bd883b&open=c1438b2b-cc20-4adc-bd63-ce02b6bd883b

/**
* Register the Azure KMS driver with the KMS registry.
*/
Expand All @@ -22,15 +38,88 @@

newKmsClient(config: Map<string, string>, 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<string, string>): 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/<version>"). 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<string, string>, kmsKeyId: string): Promise<string> {
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}`)
}
Comment thread
Copilot marked this conversation as resolved.
if (key == null || key.id == null) {

Check warning on line 100 in schemaregistry/rules/encryption/azurekms/azure-driver.ts

View check run for this annotation

SonarQube-Confluent / SonarQube Code Analysis

Prefer using an optional chain expression instead, as it's more concise and easier to read.

[S6582] Optional chaining should be preferred See more on https://sonarqube.confluent.io/project/issues?id=confluent-kafka-javascript&pullRequest=507&issues=c8312590-04d1-4371-8908-acda73f35734&open=c8312590-04d1-4371-8908-acda73f35734
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}
}
}
20 changes: 18 additions & 2 deletions schemaregistry/rules/encryption/encrypt-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> {
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)
}
Comment thread
rayokota marked this conversation as resolved.
}
return aeadConfig
}

supported(keyUri: string): boolean {
return this.kekId === keyUri
}

async encrypt(rawKey: Buffer): Promise<Buffer> {
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) {
Expand All @@ -690,9 +705,10 @@ export class KmsClientWrapper implements KmsClient {
}

async decrypt(encryptedKey: Buffer): Promise<Buffer> {
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) {
Expand Down
Loading