使用 PowerShell 轮换 Always Encrypted 密钥

适用于:SQL ServerAzure SQL 数据库Azure SQL 托管实例

本文提供使用 SqlServer PowerShell 模块来轮换“Always Encrypted”密钥的步骤。 有关如何开始将 SqlServer PowerShell 模块用于 Always Encrypted 的信息,请参阅 使用 PowerShell 配置 Always Encrypted

注意

Microsoft建议在运行 Always Encrypted PowerShell 脚本时使用 PowerShell 7 或更高版本。 PowerShell 7 提供了改进的跨平台支持、更好的性能和与 SqlServer 模块(v22+)的最新兼容性,这是许多 Always Encrypted 方案所必需的。

轮换 Always Encrypted 密钥是将现有密钥替换为新密钥的过程。 如果密钥遭到入侵,或者遵守组织强制定期轮换加密密钥的策略或合规性法规,则可能需要轮换密钥。

“始终加密”使用两种类型的密钥,因此有两个高级密钥轮换工作流:轮换列主密钥和轮换列加密密钥。

  • 列加密密钥轮换 - 包括解密使用当前密钥加密的数据,和使用新的列加密密钥对数据进行加密。 由于轮换列加密密钥需要访问密钥和数据库,因此在不分离角色的情况下,只能执行列加密密钥轮换。
  • 列主密钥转换 - 包括解密使用当前列主密钥保护的列加密密钥,使用新的列主密钥重新加密列加密密钥,以及更新这两种类型的密钥的元数据。 可以使用或不使用角色分离来完成列主密钥轮换(当使用 SqlServer PowerShell 模块时)。

不使用角色分离的列主密钥轮换

本部分中用于轮换列主密钥的方法不支持安全管理员和 DBA 之间的角色分离。 以下步骤中的一些操作将物理密钥的操作与密钥元数据上的操作相结合。 如果你的组织使用 DevOps 模型,或者当数据库托管在云中并且主要目标是限制云管理员(而不是本地 DBA)访问敏感数据时,请使用此工作流。 如果潜在的对手包括 DBA,或者 DBA 不应有权访问敏感数据,请不要使用此方法。

任务 文章 访问纯文本密钥/密钥存储 访问数据库
步骤 1. 在密钥存储中创建新的列主密钥。

注意:SqlServer PowerShell 模块不支持这一步。 若要从命令行完成此任务,需要使用特定于你的密钥存储的工具。 将 Azure 密钥保管库 用作密钥存储时,不支持多租户客户管理的密钥轮换。 确保新的客户托管密钥与现有密钥位于同一租户中。
创建并存储 Always Encrypted 的列主密钥
步骤 2. 启动 PowerShell 环境并导入 SqlServer 模块 导入 SqlServer 模块
步骤 3. 连接到服务器和数据库。 连接到数据库
步骤 4. 创建包含 新的 列主密钥的位置信息的 SqlColumnMasterKeySettings 对象。 SqlColumnMasterKeySettings 是存在于内存中的对象(在 PowerShell 中)。 若要创建该对象,需要使用特定于你的密钥存储的 cmdlet。 New-SqlAzureKeyVaultColumnMasterKeySettings

New-SqlCertificateStoreColumnMasterKeySettings (新建-SQL证书存储列主密钥设置)

New-SqlCngColumnMasterKeySettings 新建-SqlCng列主密钥设置

New-SqlCspColumnMasterKeySettings
步骤 5。 在数据库中创建有关新的列主密钥的元数据。 New-SqlColumnMasterKey

注意: 在幕后,此 cmdlet 发出 CREATE COLUMN MASTER KEY 创建密钥元数据的 (Transact-SQL) 语句。
步骤 6。 如果当前的列主密钥或新的列主密钥存储在 Azure 密钥保管库 的密钥保管库或托管 HSM 中,请对 Azure 进行身份验证 Connect-AzAccount
步骤 7. 如果列主密钥存储在 Azure 密钥保管库 中,请获取 Azure 密钥保管库 的访问令牌。 Get-AzAccessToken
步骤 8。 开始轮换,方法是使用新的列主密钥对当前由旧列主密钥保护的每个列加密密钥进行加密。 在此步骤完成后,每个受影响的列加密密钥(与即将轮换的旧列主密钥相关联)均会使用旧的和新的列主密钥进行加密,并且在数据库元数据中会有两个加密值。 Invoke-SqlColumnMasterKeyRotation
步骤 9. 与查询数据库中的加密列(使用旧的列主密钥保护)的所有应用程序的管理员协调,以便他们可以确保应用程序可以访问新的列主密钥。 创建并存储列主密钥 (Always Encrypted)
步骤 10. 完成旋转

注意: 执行此步骤之前,请确保查询使用旧的列主密钥保护的加密列的所有应用程序已配置为使用新的列主密钥。 如果过早地执行此步骤,一些应用程序可能无法对数据进行解密。 通过从数据库中删除使用旧的列主密钥创建的加密值来完成轮换。 该操作将删除旧的列主密钥和它所保护的列加密密钥之间的关联。
Complete-SqlColumnMasterKeyRotation
步骤 11. 从旧的列主密钥中删除元数据。 Remove-SqlColumnMasterKey

注意

强烈建议你不要在轮换后永久地删除旧的列主密钥。 您应该将旧的列主密钥保留在当前的密钥存储中,或者将其存档在另一个安全的位置。 如果在配置新的列主密钥之前从备份文件中将数据库还原到某个时间点,则需要此旧密钥来访问数据。

不使用角色分离轮换列主密钥(Windows 证书示例)

以下脚本是一个端到端示例,用于演示使用新的列主密钥 (CMK2) 替换现有的列主密钥 (CMK1)。

[CmdletBinding()]
param(
	[Parameter(Mandatory = $false)]
	[string]$ServerName = '<server name>',

	[Parameter(Mandatory = $false)]
	[ValidateNotNullOrEmpty()]
	[string]$DatabaseName = '<database name>',

	[Parameter(Mandatory = $false)]
	[string]$CertificateSubject = 'AlwaysEncryptedCertNew',

	[Parameter(Mandatory = $false)]
	[ValidateNotNullOrEmpty()]
	[string]$OldCmkName = 'CMK1',

	[Parameter(Mandatory = $false)]
	[ValidateNotNullOrEmpty()]
	[string]$NewCmkName = 'CMK2'
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

Import-Module SqlServer -MinimumVersion 22.0.50 -ErrorAction Stop

Write-Host '[AE] Step 1: Creating a new self-signed certificate for the new CMK'
$cert = New-SelfSignedCertificate `
	-Subject $CertificateSubject `
	-CertStoreLocation 'Cert:CurrentUser\My' `
	-KeyExportPolicy Exportable `
	-Type DocumentEncryptionCert `
	-KeyUsage KeyEncipherment `
	-KeySpec KeyExchange `
	-KeyLength 2048
Write-Host "[AE] Certificate created with thumbprint: $($cert.Thumbprint)"

Write-Host "[AE] Step 2: Connecting to SQL Server '$ServerName' / Database '$DatabaseName'"
$connStr = "Server=$ServerName;Database=$DatabaseName;Integrated Security=True;Encrypt=True;TrustServerCertificate=True;Connection Timeout=30"

try {
	$database = Get-SqlDatabase -ConnectionString $connStr -ErrorAction Stop
}
catch {
	Write-Error "Failed to connect to '$ServerName' / '$DatabaseName'. Verify instance, database, and local permissions."
	throw
}

Write-Host "[AE] Step 3: Validating that old CMK '$OldCmkName' exists"
$oldCmk = Get-SqlColumnMasterKey -InputObject $database | Where-Object { $_.Name -eq $OldCmkName }
if (-not $oldCmk) {
	throw "Old CMK '$OldCmkName' does not exist. Cannot rotate."
}
Write-Host "[AE] Old CMK '$OldCmkName' found."

Write-Host "[AE] Step 4: Creating CMK settings for new certificate"
$newCmkSettings = New-SqlCertificateStoreColumnMasterKeySettings -CertificateStoreLocation 'CurrentUser' -Thumbprint $cert.Thumbprint

Write-Host "[AE] Step 5: Registering new CMK '$NewCmkName' in the database"
$newCmk = Get-SqlColumnMasterKey -InputObject $database | Where-Object { $_.Name -eq $NewCmkName }
if ($newCmk) {
	Write-Host "[AE] New CMK '$NewCmkName' already exists. Skipping creation."
}
else {
	New-SqlColumnMasterKey -Name $NewCmkName -InputObject $database -ColumnMasterKeySettings $newCmkSettings | Out-Null
	Write-Host "[AE] New CMK '$NewCmkName' registered."
}

Write-Host "[AE] Step 6: Initiating CMK rotation from '$OldCmkName' to '$NewCmkName'"
Write-Host "[AE] (This re-encrypts all associated CEKs under the new CMK...)"
Invoke-SqlColumnMasterKeyRotation `
	-SourceColumnMasterKeyName $OldCmkName `
	-TargetColumnMasterKeyName $NewCmkName `
	-InputObject $database
Write-Host "[AE] Rotation initiated."

Write-Host "[AE] Step 7: Completing the CMK rotation"
Complete-SqlColumnMasterKeyRotation `
	-SourceColumnMasterKeyName $OldCmkName `
	-InputObject $database
Write-Host "[AE] Rotation completed."

Write-Host "[AE] Step 8: Verifying CEKs are now under '$NewCmkName'"
$query = "SELECT name FROM sys.column_encryption_keys WHERE name = N'$($NewCmkName)'"
$rotatedCeks = Invoke-SqlCmd -ServerInstance $ServerName -Database $DatabaseName -Query $query -TrustServerCertificate  -ErrorAction SilentlyContinue
if ($rotatedCeks) {
	$cekCount = @($rotatedCeks).Count
	if ($cekCount -eq 0) { $cekCount = 1 }
	Write-Host "[AE] Verified: $cekCount CEK(s) now under '$NewCmkName'"
	@($rotatedCeks) | ForEach-Object { Write-Host "  - $($_.name)" }
}

Write-Host "[AE] Step 9: Removing old CMK metadata '$OldCmkName'"
Remove-SqlColumnMasterKey -Name $OldCmkName -InputObject $database
Write-Host "[AE] Old CMK '$OldCmkName' removed."

Write-Host '[AE] ========== Rotation Complete =========='
Write-Host "[AE] Old CMK: $OldCmkName (deleted)"
Write-Host "[AE] New CMK: $NewCmkName (active)"
Write-Host '[AE] All CEKs have been re-encrypted under the new CMK.'

使用角色分离的列主密钥轮换

本节中所述的列主密钥轮换工作流可以确保安全管理员和 DBA 之间的角色分离。

重要

在执行以下表格中“访问纯文本密钥/密钥存储”=的任意步骤(即访问纯文本密钥或密钥存储的步骤)之前,请确保 PowerShell 环境在一台安全的机器上运行,并且这台机器与托管您的数据库的计算机不同。 有关详细信息,请参阅 密钥管理的安全注意事项

第 1 部分:DBA

DBA 将检索要轮换的列主密钥的元数据,以及与当前列主密钥关联的受影响的列加密密钥的元数据。 DBA 与安全管理员共享所有此信息。

任务 文章 访问纯文本密钥/密钥存储 访问数据库
步骤 1. 启动 PowerShell 环境并导入 SqlServer 模块。 导入 SqlServer 模块
步骤 2. 连接到服务器和数据库。 连接到数据库
步骤 3. 检索旧的列主密钥的元数据。 获取-Sql列主密钥
步骤 4. 检索旧的列主密钥保护的列加密密钥的元数据,包括其加密的值。 Get-SqlColumnEncryptionKey
步骤 5。 共享列主密钥的位置信息(包括提供程序名称和列主密钥的密钥路径),以及使用旧列主密钥保护的相应列加密密钥的加密值。 请参下面的示例。

第 2 部分:安全管理员

安全管理员生成新的列主密钥,使用该新的列主密钥重新加密受影响的列加密密钥,与 DBA 共享有关新的列主密钥以及受影响的列加密密钥的新加密值集的信息。

任务 文章 访问纯文本密钥/密钥存储 访问数据库
步骤 1. 从 DBA 处获取旧列主密钥的位置以及使用旧列主密钥保护的相应列加密密钥的加密值。 不适用
请参下面的示例。
步骤 2. 在密钥存储中创建新的列主密钥。

注意:SqlServer 模块不支持这一步。 若要从命令行完成此任务,你需要使用特定于密钥存储类型的工具。 将 Azure 密钥保管库 用作密钥存储时,不支持多租户客户管理的密钥轮换。 确保新的客户托管密钥与现有密钥位于同一租户中。
创建并存储 Always Encrypted 的列主密钥
步骤 3. 启动 PowerShell 环境并导入 SqlServer 模块。 导入 SqlServer 模块
步骤 4. 创建包含 旧的 列主密钥的位置信息的 SqlColumnMasterKeySettings 对象。 SqlColumnMasterKeySettings 是存在于内存中的对象(在 PowerShell 中)。 New-SqlColumnMasterKeySettings
步骤 5。 创建包含 新的 列主密钥的位置信息的 SqlColumnMasterKeySettings 对象。 SqlColumnMasterKeySettings 是存在于内存中的对象(在 PowerShell 中)。 若要创建该对象,需要使用特定于你的密钥存储的 cmdlet。 New-SqlAzureKeyVaultColumnMasterKeySettings

New-SqlCertificateStoreColumnMasterKeySettings (新建-SQL证书存储列主密钥设置)

New-SqlCngColumnMasterKeySettings 新建-SqlCng列主密钥设置

New-SqlCspColumnMasterKeySettings
步骤 6。 如果旧的(当前)列主密钥或新的列主密钥存储在 Azure 密钥保管库 的密钥保管库或托管 HSM 中,请对 Azure 进行身份验证。 Connect-AzAccount
步骤 7. 如果列主密钥存储在 Azure 密钥保管库 中,请获取 Azure 密钥保管库 的访问令牌。 Get-AzAccessToken
步骤 8。 使用新的列主密钥重新加密当前使用旧列主密钥保护的每个列加密密钥的值。 New-SqlColumnEncryptionKeyEncryptedValue

注意: 调用此 cmdlet 时,将传递旧的和新的列主密钥的 SqlColumnMasterKeySettings 对象,以及要重新加密的列加密密钥的值。
步骤 9. 与 DBA 共享新的列主密钥的位置(提供程序名称和列主密钥的密钥路径)以及列加密密钥的新加密值组。 请参下面的示例。

注意

强烈建议你不要在轮换后永久地删除旧的列主密钥。 您应该将旧的列主密钥保留在当前的密钥存储中,或者将其存档在另一个安全的位置。 如果在配置新的列主密钥之前从备份文件中将数据库还原到某个时间点,则需要此旧密钥来访问数据。

第 3 部分:DBA

DBA 创建新的列主密钥的元数据,并更新受影响的列加密密钥的元数据,以便添加新的加密值集合。 在此步骤中,DBA 还与查询加密列的应用程序的管理员协调,该管理员将确保应用程序可以访问新的列主密钥。 一旦所有应用程序设置为使用新的列主密钥,DBA 将删除旧的加密值集和旧的列主密钥元数据。

任务 文章 访问纯文本密钥/密钥存储 访问数据库
步骤 1. 从安全管理员处获取新的列主密钥的位置,以及使用旧列主密钥保护的相应列加密密钥的新加密值集合。 请参下面的示例。
步骤 2. 启动 PowerShell 环境并导入 SqlServer 模块。 导入 SqlServer 模块
步骤 3. 连接到服务器和数据库。 连接到数据库
步骤 4. 创建包含 新的 列主密钥的位置信息的 SqlColumnMasterKeySettings 对象。 SqlColumnMasterKeySettings 是存在于内存中的对象(在 PowerShell 中)。 New-SqlColumnMasterKeySettings
步骤 5。 在数据库中创建有关新的列主密钥的元数据。 New-SqlColumnMasterKey

注意:此 cmdlet 在后台实际上会发出 CREATE COLUMN MASTER KEY(Transact-SQL) 语句,以创建密钥元数据。
步骤 6。 检索旧的列主密钥保护的列加密密钥的元数据。 Get-SqlColumnEncryptionKey
步骤 7. 将使用新的列主密钥生成的新加密值添加到每个受影响列加密密钥的元数据中。 Add-SqlColumnEncryptionKeyValue
步骤 8。 与查询数据库中的加密列(使用旧的列主密钥保护)的所有应用程序的管理员协调,以便他们可以确保应用程序可以访问新的列主密钥。 创建并存储列主密钥 (Always Encrypted)
步骤 9. 通过从数据库中删除与旧的列主密钥关联的加密值来完成轮换。

注意: 执行此步骤之前,请确保查询使用旧的列主密钥保护的加密列的所有应用程序已配置为使用新的列主密钥。 如果过早地执行此步骤,一些应用程序可能无法对数据进行解密。

该步骤将删除旧的列主密钥和它所保护的列加密密钥之间的关联。
Complete-SqlColumnMasterKeyRotation

或者,可以使用 Remove-SqlColumnEncryptionKeyValue
步骤 10. 从数据库中删除旧的列主密钥的元数据 Remove-SqlColumnMasterKey

使用角色分离轮换列主密钥(Windows 证书示例)

以下脚本是端到端示例,用于生成作为 Windows 证书存储中的证书的新列主密钥,轮换现有(当前)的列主密钥,以将其替换为新的列主密钥。 该脚本假设目标数据库包含一个名为 CMK1 的列主密钥(将被轮换),用于加密一些列加密密钥。

第 1 部分:DBA

[CmdletBinding()]
param(
	[Parameter(Mandatory = $false)]
	[ValidateNotNullOrEmpty()]
	[string]$ServerName = '<server name>',

	[Parameter(Mandatory = $false)]
	[ValidateNotNullOrEmpty()]
	[string]$DatabaseName = '<database name>',

	[Parameter(Mandatory = $false)]
	[ValidateNotNullOrEmpty()]
	[string]$OldCmkName = 'CMK2',

	[Parameter(Mandatory = $false)]
	[ValidateNotNullOrEmpty()]
	[string]$OutputFolder = 'C:\temp'
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

Import-Module SqlServer -MinimumVersion 22.0.50 -ErrorAction Stop

Write-Host "[CEK Export] Starting CMK and CEK data export"

# Validate output folder
if (-not (Test-Path -Path $OutputFolder -PathType Container)) {
	Write-Host "[CEK Export] Creating output folder: $OutputFolder"
	New-Item -Path $OutputFolder -ItemType Directory | Out-Null
}

# Connect to database
Write-Host "[CEK Export] Connecting to '$ServerName' / '$DatabaseName'"
$connStr = "Server=$ServerName;Database=$DatabaseName;Integrated Security=True;TrustServerCertificate=True;Connection Timeout=30"

try {
	$database = Get-SqlDatabase -ConnectionString $connStr -ErrorAction Stop
}
catch {
	Write-Error "Failed to connect to '$ServerName' / '$DatabaseName'."
	throw
}

# Retrieve old CMK
Write-Host "[CEK Export] Retrieving CMK '$OldCmkName'"
$oldCmk = Get-SqlColumnMasterKey -InputObject $database | Where-Object { $_.Name -eq $OldCmkName }
if (-not $oldCmk) {
	throw "CMK '$OldCmkName' not found in database '$DatabaseName'."
}

# Export CMK metadata using fixed text file name
$cmkFile = Join-Path $OutputFolder "oldcmkdata.txt"
Write-Host "[CEK Export] Exporting CMK metadata to: $cmkFile"
"CMKName|KeyStoreProviderName|KeyPath" | Set-Content -Path $cmkFile -Encoding UTF8
"$OldCmkName|$($oldCmk.KeyStoreProviderName)|$($oldCmk.KeyPath)" | Add-Content -Path $cmkFile -Encoding UTF8
Write-Host "[CEK Export]   ✓ CMK metadata exported"

# Discover and export CEKs using fixed text file name
Write-Host "[CEK Export] Discovering CEKs associated with '$OldCmkName'"
$ceks = Get-SqlColumnEncryptionKey -InputObject $database
$cekFile = Join-Path $OutputFolder "oldcekvalues.txt"
"CEKName|CEKEncryptedValue|HasMultipleEncryptedValues" | Set-Content -Path $cekFile -Encoding UTF8

$exportedCount = 0
$multiValueCount = 0

foreach ($cek in $ceks) {
	if (-not $cek.ColumnEncryptionKeyValues) {
		continue
	}

	# Check if this CEK has multiple encrypted values
	if ($cek.ColumnEncryptionKeyValues.Count -gt 1) {
		# CEK has multiple encrypted values - check if any reference the old CMK
		$refersToOldCmk = $cek.ColumnEncryptionKeyValues | Where-Object { $_.ColumnMasterKeyName -eq $OldCmkName }
		if ($refersToOldCmk) {
			Write-Warning "CEK '$($cek.Name)' has $($cek.ColumnEncryptionKeyValues.Count) encrypted values. One references '$OldCmkName'. This CEK cannot be rotated automatically."
			"$($cek.Name)|MULTIPLE_ENCRYPTED_VALUES|True" | Add-Content -Path $cekFile -Encoding UTF8
			$multiValueCount++
		}
	}
	else {
		# CEK has single encrypted value - check if it references the old CMK
		if ($cek.ColumnEncryptionKeyValues[0].ColumnMasterKeyName -eq $OldCmkName) {
			$encryptedValueHex = "0x" + -join ($cek.ColumnEncryptionKeyValues[0].EncryptedValue | ForEach-Object { $_.ToString("X2") })
			"$($cek.Name)|$encryptedValueHex|False" | Add-Content -Path $cekFile -Encoding UTF8
			$exportedCount++
		}
	}
}

Write-Host "[CEK Export]   ✓ CEK encrypted values exported"
Write-Host "[CEK Export]     - Exported: $exportedCount CEK(s)"
if ($multiValueCount -gt 0) {
	Write-Warning "      - Multi-valued CEKs (manual review needed): $multiValueCount"
}

Write-Host "[CEK Export] ===== Export Complete ====="
Write-Host "[CEK Export] CMK Metadata:   $cmkFile"
Write-Host "[CEK Export] CEK Values:     $cekFile"

第 2 部分:安全管理员

[CmdletBinding()]
param(
    [Parameter(Mandatory = $false)]
    [ValidateNotNullOrEmpty()]
    [string]$ShareFolder = 'C:\Temp\',

    [Parameter(Mandatory = $false)]
    [ValidateSet('CurrentUser', 'LocalMachine')]
    [string]$StoreLocation = 'CurrentUser',

    [Parameter(Mandatory = $false)]
    [ValidateNotNullOrEmpty()]
    [string]$CertificateSubject = 'AlwaysEncryptedCert'
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

Import-Module SqlServer -MinimumVersion 22.0.50 -ErrorAction Stop

function Import-DelimitedTextFile {
    param(
        [Parameter(Mandatory = $true)] [string]$Path,
        [Parameter(Mandatory = $true)] [string[]]$RequiredColumns
    )

    if (-not (Test-Path -Path $Path -PathType Leaf)) {
        throw "Required file not found: $Path"
    }

    $raw = Get-Content -Path $Path -Raw
    if ([string]::IsNullOrWhiteSpace($raw)) {
        throw "File is empty: $Path"
    }

    $delimiter = if ($raw -match '\|') { '|' } else { ',' }
    $rows = @(Import-Csv -Path $Path -Delimiter $delimiter)
    if ($rows.Count -eq 0) {
        throw "No data rows found in file: $Path"
    }

    $first = $rows[0]
    $RequiredColumns | ForEach-Object {
        if (-not $first.PSObject.Properties[$_]) {
            throw "Missing required column '$_' in file: $Path"
        }
    }

    return $rows
}

if (-not (Test-Path -Path $ShareFolder -PathType Container)) {
    throw "Share folder does not exist: $ShareFolder"
}

$oldCmkDataFile = Join-Path $ShareFolder 'oldcmkdata.txt'
$oldCekValuesFile = Join-Path $ShareFolder 'oldcekvalues.txt'
$newCmkDataFile = Join-Path $ShareFolder 'newcmkdata.txt'
$newCekValuesFile = Join-Path $ShareFolder 'newcekvalues.txt'

Write-Host "[AE] Reading old CMK data from '$oldCmkDataFile'"
$oldCmkDataRows = Import-DelimitedTextFile -Path $oldCmkDataFile -RequiredColumns @('KeyStoreProviderName', 'KeyPath')
$oldCmkData = $oldCmkDataRows[0]

Write-Host "[AE] Reading old CEK values from '$oldCekValuesFile'"
$oldCekValues = Import-DelimitedTextFile -Path $oldCekValuesFile -RequiredColumns @('CEKName', 'CEKEncryptedValue')

Write-Host "[AE] Finding or creating certificate '$CertificateSubject' in $StoreLocation\\My"
$certPath = "Cert:$StoreLocation\My"
$cert = Get-ChildItem -Path $certPath |
    Where-Object { $_.Subject -eq "CN=$CertificateSubject" } |
    Sort-Object NotAfter -Descending |
    Select-Object -First 1

if (-not $cert) {
    $cert = New-SelfSignedCertificate `
        -Subject $CertificateSubject `
        -CertStoreLocation $certPath `
        -KeyExportPolicy Exportable `
        -Type DocumentEncryptionCert `
        -KeyUsage DataEncipherment `
        -KeySpec KeyExchange
}

Write-Host '[AE] Building CMK settings'
$oldCmkSettings = New-SqlColumnMasterKeySettings `
    -KeyStoreProviderName $oldCmkData.KeyStoreProviderName `
    -KeyPath $oldCmkData.KeyPath

$newCmkSettings = New-SqlCertificateStoreColumnMasterKeySettings `
    -CertificateStoreLocation $StoreLocation `
    -Thumbprint $cert.Thumbprint

Write-Host "[AE] Re-encrypting CEK values and writing '$newCekValuesFile'"
"CEKName|CEKEncryptedValue" | Set-Content -Path $newCekValuesFile -Encoding UTF8

$oldCekValues | ForEach-Object {
    $newValue = New-SqlColumnEncryptionKeyEncryptedValue `
        -TargetColumnMasterKeySettings $newCmkSettings `
        -ColumnMasterKeySettings $oldCmkSettings `
        -EncryptedValue $_.CEKEncryptedValue

    "$($_.CEKName)|$newValue" | Add-Content -Path $newCekValuesFile -Encoding UTF8
}

Write-Host "[AE] Writing new CMK data to '$newCmkDataFile'"
"KeyStoreProviderName|KeyPath" | Set-Content -Path $newCmkDataFile -Encoding UTF8
"$($newCmkSettings.KeyStoreProviderName)|$($newCmkSettings.KeyPath)" | Add-Content -Path $newCmkDataFile -Encoding UTF8

Write-Host '[AE] Completed successfully'
Write-Host "[AE] Output files: $newCmkDataFile , $newCekValuesFile"

第 3 部分:DBA

[CmdletBinding()]
param(
    [Parameter(Mandatory = $false)]
    [ValidateNotNullOrEmpty()]
    [string]$ServerName = '<server name>',

    [Parameter(Mandatory = $false)]
    [ValidateNotNullOrEmpty()]
    [string]$DatabaseName = '<database name>',

    [Parameter(Mandatory = $false)]
    [ValidateNotNullOrEmpty()]
    [string]$OldCmkName = 'CMK1',

    [Parameter(Mandatory = $false)]
    [ValidateNotNullOrEmpty()]
    [string]$NewCmkName = 'CMK2',

    [Parameter(Mandatory = $false)]
    [ValidateNotNullOrEmpty()]
    [string]$InputFolder = 'C:\temp'
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

Import-Module SqlServer -MinimumVersion 22.0.50 -ErrorAction Stop

function Import-DelimitedTextFile {
    param(
        [Parameter(Mandatory = $true)] [string]$Path,
        [Parameter(Mandatory = $true)] [string[]]$RequiredColumns
    )

    if (-not (Test-Path -Path $Path -PathType Leaf)) {
        throw "Required file not found: $Path"
    }

    $raw = Get-Content -Path $Path -Raw
    if ([string]::IsNullOrWhiteSpace($raw)) {
        throw "File is empty: $Path"
    }

    $delimiter = if ($raw -match '\|') { '|' } else { ',' }
    $rows = @(Import-Csv -Path $Path -Delimiter $delimiter)
    if ($rows.Count -eq 0) {
        throw "No data rows found in file: $Path"
    }

    $first = $rows[0]
    $RequiredColumns | ForEach-Object {
        if (-not $first.PSObject.Properties[$_]) {
            throw "Missing required column '$_' in file: $Path"
        }
    }

    return $rows
}

if (-not (Test-Path -Path $InputFolder -PathType Container)) {
    throw "Input folder not found: $InputFolder"
}

$newCmkDataFile = Join-Path $InputFolder 'newcmkdata.txt'
$newCekValuesFile = Join-Path $InputFolder 'newcekvalues.txt'

Write-Host "[AE] Reading new CMK data from '$newCmkDataFile'"
$newCmkRows = Import-DelimitedTextFile -Path $newCmkDataFile -RequiredColumns @('KeyStoreProviderName', 'KeyPath')
$newCmkData = $newCmkRows[0]

Write-Host "[AE] Reading new CEK values from '$newCekValuesFile'"
$newCekValues = Import-DelimitedTextFile -Path $newCekValuesFile -RequiredColumns @('CEKName', 'CEKEncryptedValue')

Write-Host "[AE] Connecting to '$ServerName' / '$DatabaseName'"
$connStr = "Server=$ServerName;Database=$DatabaseName;Integrated Security=True;TrustServerCertificate=True;Connection Timeout=30"
$database = Get-SqlDatabase -ConnectionString $connStr -ErrorAction Stop

Write-Host "[AE] Ensuring target CMK '$NewCmkName' exists"
$newCmkSettings = New-SqlColumnMasterKeySettings -KeyStoreProviderName $newCmkData.KeyStoreProviderName -KeyPath $newCmkData.KeyPath
$existingNewCmk = Get-SqlColumnMasterKey -InputObject $database | Where-Object { $_.Name -eq $NewCmkName }
if (-not $existingNewCmk) {
    New-SqlColumnMasterKey -Name $NewCmkName -InputObject $database -ColumnMasterKeySettings $newCmkSettings | Out-Null
}

Write-Host "[AE] Adding new encrypted CEK values under '$NewCmkName'"
$ceks = Get-SqlColumnEncryptionKey -InputObject $database

$ceksToRotate = @(
    $ceks | Where-Object {
        $_.ColumnEncryptionKeyValues -and
        @($_.ColumnEncryptionKeyValues | Where-Object { $_.ColumnMasterKeyName -eq $OldCmkName }).Count -gt 0
    }
)

$ceksToRotate | ForEach-Object {
    $cek = $_
    if (@($cek.ColumnEncryptionKeyValues).Count -gt 1) {
        throw "CEK '$($cek.Name)' already has multiple encrypted values and still references '$OldCmkName'."
    }

    $newValueRow = @($newCekValues | Where-Object { $_.CEKName -eq $cek.Name }) | Select-Object -First 1
    if (-not $newValueRow) {
        throw "No new encrypted value found for CEK '$($cek.Name)' in file '$newCekValuesFile'."
    }

    Add-SqlColumnEncryptionKeyValue `
        -ColumnMasterKeyName $NewCmkName `
        -Name $cek.Name `
        -EncryptedValue $newValueRow.CEKEncryptedValue `
        -InputObject $database | Out-Null
}

Write-Host "[AE] Completing rotation for source CMK '$OldCmkName'"
Complete-SqlColumnMasterKeyRotation -SourceColumnMasterKeyName $OldCmkName -InputObject $database

Write-Host "[AE] Removing source CMK '$OldCmkName' metadata"
Remove-SqlColumnMasterKey -Name $OldCmkName -InputObject $database

Write-Host '[AE] Completed successfully'

轮换列加密密钥

轮换列加密密钥包括解密使用要轮换的密钥加密的所有列中的数据,和使用新的列加密密钥对数据进行重新加密。 因为此轮换工作流需要访问密钥和数据库,所以无法在使用角色分隔的情况下执行。 如果表包含使用要轮换的密钥进行加密的列且很大,那么轮换列加密密钥可能需要很长时间才能完成。 因此,组织需要谨慎地计划列加密密钥轮换。

可使用脱机或联机方法轮换列加密密钥。 前一种方法可能速度更快,但应用程序无法向受影响的表写入数据。 后一种方法可能耗时更长,但你可以限制受影响的表不可用于应用程序的时间间隔。 有关更多信息,请参阅使用 PowerShell 配置 Always Encrypted 进行列加密Set-SqlColumnEncryption

任务 文章 访问纯文本密钥/密钥存储 访问数据库
步骤 1. 启动 PowerShell 环境并导入 SqlServer 模块。 导入 SqlServer 模块
步骤 2. 连接到服务器和数据库。 连接到数据库
步骤 3. 如果您的列主密钥(用来保护要轮换的列加密密钥)存储在 Azure 密钥保管库或托管 HSM 中,请进行 Azure 身份验证。 Connect-AzAccount
步骤 4. 如果列主密钥存储在 Azure 密钥保管库 中,请获取 Azure 密钥保管库 的访问令牌。 Get-AzAccessToken
步骤 5。 生成新的列加密密钥,使用列主密钥对其加密,并在数据库中创建列加密密钥元数据。 New-SqlColumnEncryptionKey

注意: 使用能够内部生成并加密列加密密钥的 cmdlet 版本。
在内部,此 cmdlet 会发出 CREATE COLUMN ENCRYPTION KEY(Transact-SQL) 语句来创建密钥元数据。
步骤 6。 查找使用旧的列加密密钥加密的所有列。 SQL Server 管理对象 (SMO) 编程指南
步骤 7. 为每个受影响的列创建 SqlColumnEncryptionSettings 对象。 SqlColumnEncryptionSettings 是存在于内存中的对象(在 PowerShell 中)。 它用于指定列的目标加密方案。 在此情况下,该对象应指定使用新的列加密密钥加密受影响的列。 New-SqlColumnEncryptionSettings
步骤 8。 使用新的列加密密钥重新加密步骤 5 中标识的列。 Set-SqlColumnEncryption

注意: 此步骤可能需要较长时间。 应用程序无法通过整个操作或部分操作访问表,具体视你选择的方法(联机与脱机)而定。
步骤 9. 删除旧的列加密密钥的元数据。 移除-SqlColumnEncryptionKey

示例 - 轮换列加密密钥

下面的脚本演示了如何轮换列加密密钥。 此脚本假定,目标数据库中包含一些使用列加密密钥(即要轮换的密钥 CEK1)进行加密的列,但使用列主密钥 CMK1(列主密钥未存储在 Azure 密钥保管库 中)获得保护。

[CmdletBinding()]
param(
    [Parameter(Mandatory = $false)]
    [ValidateNotNullOrEmpty()]
    [string]$ServerName = '<server name>',

    [Parameter(Mandatory = $false)]
    [ValidateNotNullOrEmpty()]
    [string]$DatabaseName = '<database name>',

    [Parameter(Mandatory = $false)]
    [ValidateNotNullOrEmpty()]
    [string]$OldCekName = 'CEK1',

    [Parameter(Mandatory = $false)]
    [ValidateNotNullOrEmpty()]
    [string]$NewCekName = 'CEK2',

    [Parameter(Mandatory = $false)]
    [ValidateNotNullOrEmpty()]
    [string]$CmkName = 'CMK2',

    [Parameter(Mandatory = $false)]
    [ValidateRange(0, 3600)]
    [int]$MaxDowntimeInSeconds = 120,

    [Parameter(Mandatory = $false)]
    [ValidateNotNullOrEmpty()]
    [string]$LogFileDirectory = '.'
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

Import-Module SqlServer -MinimumVersion 22.0.50 -ErrorAction Stop

if ($OldCekName -eq $NewCekName) {
    throw 'OldCekName and NewCekName must be different.'
}

if (-not (Test-Path -Path $LogFileDirectory -PathType Container)) {
    New-Item -Path $LogFileDirectory -ItemType Directory | Out-Null
}

Write-Host "[AE] Connecting to '$ServerName' / '$DatabaseName'"
$connStr = "Server=$ServerName;Database=$DatabaseName;Integrated Security=True;TrustServerCertificate=True;Connection Timeout=30"
$database = Get-SqlDatabase -ConnectionString $connStr -ErrorAction Stop

Write-Host "[AE] Ensuring CMK '$CmkName' exists"
$cmk = Get-SqlColumnMasterKey -InputObject $database | Where-Object { $_.Name -eq $CmkName }
if (-not $cmk) {
    throw "Column master key '$CmkName' was not found."
}

Write-Host "[AE] Ensuring target CEK '$NewCekName' exists"
$existingNewCek = Get-SqlColumnEncryptionKey -InputObject $database | Where-Object { $_.Name -eq $NewCekName }
if (-not $existingNewCek) {
    New-SqlColumnEncryptionKey -Name $NewCekName -InputObject $database -ColumnMasterKey $CmkName | Out-Null
}

Write-Host "[AE] Discovering encrypted columns using '$OldCekName'"
$settings = @()
$tables = @($database.Tables)
$tables | ForEach-Object {
    $table = $_
    @($table.Columns) | ForEach-Object {
        $column = $_
        if ($column.IsEncrypted -and $column.ColumnEncryptionKeyName -eq $OldCekName) {
            $columnName = "{0}.{1}.{2}" -f $table.Schema, $table.Name, $column.Name
            $settings += New-SqlColumnEncryptionSettings -ColumnName $columnName -EncryptionType $column.EncryptionType -EncryptionKey $NewCekName
        }
    }
}

if ($settings.Count -eq 0) {
    Write-Warning "No encrypted columns found that reference '$OldCekName'. Nothing to rotate."
    return
}

Write-Host "[AE] Re-encrypting $($settings.Count) column(s) to '$NewCekName'"
Set-SqlColumnEncryption `
    -ColumnEncryptionSettings $settings `
    -InputObject $database `
    -UseOnlineApproach `
    -MaxDowntimeInSeconds $MaxDowntimeInSeconds `
    -LogFileDirectory $LogFileDirectory

Write-Host "[AE] Validating no columns still reference '$OldCekName'"
$stillUsingOld = $false
@($database.Tables) | ForEach-Object {
    @($_.Columns) | ForEach-Object {
        if ($_.IsEncrypted -and $_.ColumnEncryptionKeyName -eq $OldCekName) {
            $stillUsingOld = $true
        }
    }
}

if ($stillUsingOld) {
    throw "At least one encrypted column still references '$OldCekName'. Aborting CEK removal."
}

Write-Host "[AE] Removing old CEK '$OldCekName'"
Remove-SqlColumnEncryptionKey -Name $OldCekName -InputObject $database

Write-Host '[AE] Completed successfully'

后续步骤

另请参阅