PowerShell Setup: Control 1.15 — Encryption: Data in Transit and at Rest
Read the FSI PowerShell baseline first
Before running any command in this playbook, read the PowerShell Authoring Baseline for FSI Implementations. It is the canonical source for module version pinning, sovereign-cloud (GCC / GCC High / DoD) endpoints, mutation safety (-WhatIf / SupportsShouldProcess), and SHA-256 evidence emission. Snippets below show the encryption-specific patterns; the baseline is authoritative for the rest.
Customer Key revocation is permanent and destroys data by design
Several cmdlets in this playbook (Set-DataEncryptionPolicy -Refresh, Remove-DataEncryptionPolicy, anything that disables or deletes a vault key under an active DEP) sit on the data-purge path. Once you initiate revocation and the purge window completes, Microsoft cannot recover the affected data — by design. Always run with -WhatIf first, snapshot before-state, and require dual approval. Revocation belongs to a documented break-glass runbook, not a routine operations script.
Last Updated: April 2026
Modules Required: Az.Accounts, Az.KeyVault, ExchangeOnlineManagement, Microsoft.Online.SharePoint.PowerShell, Microsoft.Graph (for service principal lookup)
PowerShell Edition: PowerShell 7.4 LTS for Az and Graph modules; Windows PowerShell 5.1 for Microsoft.Online.SharePoint.PowerShell
Prerequisites
# Pin versions per the FSI PowerShell baseline; replace <version> with your CAB-approved value.
Install-Module Az.Accounts -RequiredVersion '<version>' -Repository PSGallery -Scope CurrentUser -AllowClobber -AcceptLicense
Install-Module Az.KeyVault -RequiredVersion '<version>' -Repository PSGallery -Scope CurrentUser -AllowClobber -AcceptLicense
Install-Module ExchangeOnlineManagement -RequiredVersion '<version>' -Repository PSGallery -Scope CurrentUser -AllowClobber -AcceptLicense
Install-Module Microsoft.Online.SharePoint.PowerShell -RequiredVersion '<version>' -Repository PSGallery -Scope CurrentUser -AllowClobber -AcceptLicense
Install-Module Microsoft.Graph -RequiredVersion '<version>' -Repository PSGallery -Scope CurrentUser -AllowClobber -AcceptLicense
1. Provision and Harden the Customer Key Vaults
<#
.SYNOPSIS
Idempotently provisions an Azure Key Vault hardened for Microsoft 365 Customer Key.
.DESCRIPTION
- Premium SKU + RSA-HSM key for Zone 3, Standard for Zone 2.
- Soft delete retention 90 days, purge protection ENABLED (irreversible).
- Adds the M365 workload service principal with Get/WrapKey/UnwrapKey only.
- Emits before/after JSON evidence with SHA-256 hash.
.PARAMETER WorkloadPrincipalName
'Office 365 Exchange Online' or 'Office 365 SharePoint Online'.
.EXAMPLE
.\New-FsiCustomerKeyVault.ps1 `
-SubscriptionId <sub> `
-ResourceGroup rg-fsi-customerkey `
-VaultName kv-fsi-ck-eo-pri-eastus `
-Location eastus `
-Sku Premium `
-KeyName m365-ck-key `
-KeyType RSA-HSM `
-KeySize 3072 `
-WorkloadPrincipalName 'Office 365 Exchange Online' `
-EvidencePath .\evidence -WhatIf
#>
[CmdletBinding(SupportsShouldProcess, ConfirmImpact='High')]
param(
[Parameter(Mandatory)] [string] $SubscriptionId,
[Parameter(Mandatory)] [string] $ResourceGroup,
[Parameter(Mandatory)] [string] $VaultName,
[Parameter(Mandatory)] [string] $Location,
[ValidateSet('Standard','Premium')] [string] $Sku = 'Premium',
[Parameter(Mandatory)] [string] $KeyName,
[ValidateSet('RSA','RSA-HSM')] [string] $KeyType = 'RSA-HSM',
[ValidateSet(2048,3072,4096)] [int] $KeySize = 3072,
[Parameter(Mandatory)] [ValidateSet('Office 365 Exchange Online','Office 365 SharePoint Online')]
[string] $WorkloadPrincipalName,
[string] $EvidencePath = '.\evidence'
)
$ErrorActionPreference = 'Stop'
New-Item -ItemType Directory -Force -Path $EvidencePath | Out-Null
$ts = Get-Date -Format 'yyyyMMddTHHmmssZ'
Start-Transcript -Path "$EvidencePath\customerkey-vault-$VaultName-$ts.log" -IncludeInvocationHeader
Connect-AzAccount -SubscriptionId $SubscriptionId | Out-Null
Set-AzContext -SubscriptionId $SubscriptionId | Out-Null
# Idempotency: get existing vault if present
$vault = Get-AzKeyVault -VaultName $VaultName -ResourceGroupName $ResourceGroup -ErrorAction SilentlyContinue
$beforeSnapshot = if ($vault) { $vault | ConvertTo-Json -Depth 8 } else { '{}' }
$beforeSnapshot | Set-Content "$EvidencePath\before-$VaultName-$ts.json"
if (-not $vault) {
if ($PSCmdlet.ShouldProcess($VaultName, "Create Key Vault $Sku in $Location with purge protection")) {
$vault = New-AzKeyVault `
-Name $VaultName `
-ResourceGroupName $ResourceGroup `
-Location $Location `
-Sku $Sku `
-EnableSoftDelete `
-SoftDeleteRetentionInDays 90 `
-EnablePurgeProtection
}
} else {
Write-Verbose "Vault $VaultName already exists; verifying hardening flags."
if (-not $vault.EnableSoftDelete -or -not $vault.EnablePurgeProtection) {
if ($PSCmdlet.ShouldProcess($VaultName, "Enable purge protection (irreversible)")) {
Update-AzKeyVault -VaultName $VaultName -ResourceGroupName $ResourceGroup -EnablePurgeProtection
}
}
}
# Create the wrapping key (idempotent: skip if exists)
$key = Get-AzKeyVaultKey -VaultName $VaultName -Name $KeyName -ErrorAction SilentlyContinue
if (-not $key) {
if ($PSCmdlet.ShouldProcess("$VaultName/$KeyName", "Create $KeyType $KeySize-bit key")) {
$key = Add-AzKeyVaultKey -VaultName $VaultName -Name $KeyName -Destination ($KeyType -replace 'RSA-HSM','HSM' -replace 'RSA','Software') -Size $KeySize
}
}
# Resolve the workload service principal (object id varies per tenant)
$sp = Get-AzADServicePrincipal -DisplayName $WorkloadPrincipalName | Select-Object -First 1
if (-not $sp) {
throw "Service principal '$WorkloadPrincipalName' not found. Confirm the workload is provisioned in this tenant."
}
if ($PSCmdlet.ShouldProcess($VaultName, "Grant '$WorkloadPrincipalName' Get/WrapKey/UnwrapKey")) {
Set-AzKeyVaultAccessPolicy `
-VaultName $VaultName `
-ObjectId $sp.Id `
-PermissionsToKeys Get,WrapKey,UnwrapKey
}
# After-state evidence with SHA-256
$after = @{
vault = (Get-AzKeyVault -VaultName $VaultName -ResourceGroupName $ResourceGroup)
key = (Get-AzKeyVaultKey -VaultName $VaultName -Name $KeyName)
grantedPrincipal = @{ DisplayName = $sp.DisplayName; ObjectId = $sp.Id }
}
$jsonPath = "$EvidencePath\after-$VaultName-$ts.json"
$after | ConvertTo-Json -Depth 10 | Set-Content $jsonPath
$hash = (Get-FileHash $jsonPath -Algorithm SHA256).Hash
[PSCustomObject]@{
file = (Split-Path $jsonPath -Leaf)
sha256 = $hash
bytes = (Get-Item $jsonPath).Length
generated_utc = $ts
control = '1.15'
operation = 'provision-customer-key-vault'
} | ConvertTo-Json | Add-Content "$EvidencePath\manifest.jsonl"
Stop-Transcript
Run twice (primary region + secondary region) before activating the DEP.
2. Exchange Online Customer Key Activation
<#
.SYNOPSIS
Creates and assigns the multi-workload (Exchange Online) Customer Key DEP.
.DESCRIPTION
Use only AFTER both Key Vaults are provisioned and the Office 365 Exchange Online
service principal has Get/WrapKey/UnwrapKey on the keys.
.EXAMPLE
.\Enable-FsiExchangeCustomerKey.ps1 `
-DepName 'FSI-EO-Root-DEP' `
-PrimaryKeyUri 'https://kv-fsi-ck-eo-pri-eastus.vault.azure.net/keys/m365-ck-key/<ver>' `
-SecondaryKeyUri 'https://kv-fsi-ck-eo-sec-westus.vault.azure.net/keys/m365-ck-key/<ver>' `
-EvidencePath .\evidence -WhatIf
#>
[CmdletBinding(SupportsShouldProcess, ConfirmImpact='High')]
param(
[Parameter(Mandatory)] [string] $DepName,
[Parameter(Mandatory)] [string] $PrimaryKeyUri,
[Parameter(Mandatory)] [string] $SecondaryKeyUri,
[string] $Description = 'FSI Customer Key DEP for Exchange Online — agent-bearing mailboxes',
[string] $EvidencePath = '.\evidence'
)
$ErrorActionPreference = 'Stop'
New-Item -ItemType Directory -Force -Path $EvidencePath | Out-Null
$ts = Get-Date -Format 'yyyyMMddTHHmmssZ'
Start-Transcript -Path "$EvidencePath\eo-customerkey-$DepName-$ts.log" -IncludeInvocationHeader
Connect-ExchangeOnline -ShowBanner:$false
# Idempotency: skip if a DEP with this name already exists
$existing = Get-M365DataAtRestEncryptionPolicy -Identity $DepName -ErrorAction SilentlyContinue
if ($existing) {
Write-Warning "DEP $DepName already exists in state '$($existing.PolicyState)'. Aborting create; use Set-/Refresh- pattern for updates."
$existing | ConvertTo-Json -Depth 6 | Set-Content "$EvidencePath\existing-$DepName-$ts.json"
} elseif ($PSCmdlet.ShouldProcess($DepName, "Create M365 Data-at-Rest DEP for Exchange Online")) {
New-M365DataAtRestEncryptionPolicy `
-Name $DepName `
-AzureKeyIDs @($PrimaryKeyUri, $SecondaryKeyUri) `
-Description $Description | Out-Null
}
if ($PSCmdlet.ShouldProcess('Tenant', "Assign DEP $DepName tenant-wide")) {
Set-M365DataAtRestEncryptionPolicyAssignment -DataEncryptionPolicy $DepName
}
# Capture state for evidence
$state = Get-M365DataAtRestEncryptionPolicy -Identity $DepName
$jsonPath = "$EvidencePath\state-$DepName-$ts.json"
$state | ConvertTo-Json -Depth 8 | Set-Content $jsonPath
[PSCustomObject]@{
file = (Split-Path $jsonPath -Leaf)
sha256 = (Get-FileHash $jsonPath -Algorithm SHA256).Hash
bytes = (Get-Item $jsonPath).Length
generated_utc = $ts
control = '1.15'
operation = 'activate-eo-customer-key'
initial_state = $state.PolicyState
} | ConvertTo-Json | Add-Content "$EvidencePath\manifest.jsonl"
Disconnect-ExchangeOnline -Confirm:$false
Stop-Transcript
Write-Host "Activation initiated. Re-check state with: Get-M365DataAtRestEncryptionPolicy -Identity $DepName" -ForegroundColor Cyan
Write-Host "Expected progression: PendingActivation -> Active (minutes to ~24h)." -ForegroundColor Cyan
3. SharePoint Online / OneDrive Customer Key Activation
Microsoft.Online.SharePoint.PowerShell is Windows PowerShell 5.1 only.
<#
.SYNOPSIS
Registers the SharePoint/OneDrive Customer Key (tenant-wide).
.DESCRIPTION
Tenant-wide one-shot registration. Cannot be re-run while activation is in progress.
.EXAMPLE
.\Enable-FsiSpoCustomerKey.ps1 `
-TenantAdminUrl 'https://contoso-admin.sharepoint.com' `
-PrimaryVault kv-fsi-ck-spo-pri-eastus -PrimaryKey m365-ck-key -PrimaryKeyVersion <ver> `
-SecondaryVault kv-fsi-ck-spo-sec-westus -SecondaryKey m365-ck-key -SecondaryKeyVersion <ver> `
-EvidencePath .\evidence -WhatIf
#>
[CmdletBinding(SupportsShouldProcess, ConfirmImpact='High')]
param(
[Parameter(Mandatory)] [string] $TenantAdminUrl,
[Parameter(Mandatory)] [string] $PrimaryVault,
[Parameter(Mandatory)] [string] $PrimaryKey,
[Parameter(Mandatory)] [string] $PrimaryKeyVersion,
[Parameter(Mandatory)] [string] $SecondaryVault,
[Parameter(Mandatory)] [string] $SecondaryKey,
[Parameter(Mandatory)] [string] $SecondaryKeyVersion,
[string] $EvidencePath = '.\evidence'
)
if ($PSVersionTable.PSEdition -ne 'Desktop') {
throw "Microsoft.Online.SharePoint.PowerShell requires Windows PowerShell 5.1 (Desktop edition). Detected: $($PSVersionTable.PSEdition) $($PSVersionTable.PSVersion)."
}
$ErrorActionPreference = 'Stop'
New-Item -ItemType Directory -Force -Path $EvidencePath | Out-Null
$ts = Get-Date -Format 'yyyyMMddTHHmmssZ'
Start-Transcript -Path "$EvidencePath\spo-customerkey-$ts.log" -IncludeInvocationHeader
Connect-SPOService -Url $TenantAdminUrl
# Idempotency: bail out if a DEP is already registered or activation is in flight
$existing = Get-SPODataEncryptionPolicy -ErrorAction SilentlyContinue
if ($existing -and $existing.State -ne 'Unregistered') {
Write-Warning "SPO Customer Key already in state '$($existing.State)'. Aborting register; rotation is a different operation."
$existing | ConvertTo-Json | Set-Content "$EvidencePath\spo-existing-$ts.json"
Disconnect-SPOService
Stop-Transcript
return
}
if ($PSCmdlet.ShouldProcess($TenantAdminUrl, "Register SPO Customer Key (tenant-wide, irreversible without revocation)")) {
Register-SPODataEncryptionPolicy `
-PrimaryKeyVaultName $PrimaryVault -PrimaryKeyName $PrimaryKey -PrimaryKeyVersion $PrimaryKeyVersion `
-SecondaryKeyVaultName $SecondaryVault -SecondaryKeyName $SecondaryKey -SecondaryKeyVersion $SecondaryKeyVersion
}
$state = Get-SPODataEncryptionPolicy
$jsonPath = "$EvidencePath\spo-state-$ts.json"
$state | ConvertTo-Json -Depth 8 | Set-Content $jsonPath
[PSCustomObject]@{
file = (Split-Path $jsonPath -Leaf)
sha256 = (Get-FileHash $jsonPath -Algorithm SHA256).Hash
generated_utc = $ts
control = '1.15'
operation = 'register-spo-customer-key'
} | ConvertTo-Json | Add-Content "$EvidencePath\manifest.jsonl"
Disconnect-SPOService
Stop-Transcript
4. TLS Posture Audit Across Tenant Endpoints
<#
.SYNOPSIS
Probes a list of public tenant endpoints and reports the negotiated TLS version + cipher.
.DESCRIPTION
Uses .NET SslStream to attempt handshakes at TLS 1.0, 1.1, 1.2, 1.3 and reports each result.
Read-only — no mutation. Safe to run in production.
#>
param(
[Parameter(Mandatory)] [string[]] $Endpoints,
[string] $EvidencePath = '.\evidence'
)
$ErrorActionPreference = 'Continue'
New-Item -ItemType Directory -Force -Path $EvidencePath | Out-Null
$ts = Get-Date -Format 'yyyyMMddTHHmmssZ'
$protocols = @(
@{ Name='Tls10'; Value=[System.Security.Authentication.SslProtocols]::Tls }
@{ Name='Tls11'; Value=[System.Security.Authentication.SslProtocols]::Tls11 }
@{ Name='Tls12'; Value=[System.Security.Authentication.SslProtocols]::Tls12 }
@{ Name='Tls13'; Value=[System.Security.Authentication.SslProtocols]::Tls13 }
)
$results = foreach ($endpoint in $Endpoints) {
foreach ($p in $protocols) {
$client = New-Object System.Net.Sockets.TcpClient
$row = [ordered]@{ Endpoint=$endpoint; Protocol=$p.Name; Negotiated=$false; Cipher=$null; Error=$null }
try {
$client.Connect($endpoint, 443)
$stream = New-Object System.Net.Security.SslStream($client.GetStream(), $false, { param($s,$c,$ch,$e) $true })
$stream.AuthenticateAsClient($endpoint, $null, $p.Value, $false)
$row.Negotiated = $true
$row.Cipher = "$($stream.CipherAlgorithm)/$($stream.HashAlgorithm) $($stream.SslProtocol)"
} catch {
$row.Error = $_.Exception.Message
} finally {
$client.Close()
}
[PSCustomObject]$row
}
}
$jsonPath = "$EvidencePath\tls-posture-$ts.json"
$results | ConvertTo-Json -Depth 4 | Set-Content $jsonPath
# Flag any endpoint where TLS 1.0 or 1.1 negotiated successfully
$weak = $results | Where-Object { $_.Negotiated -and $_.Protocol -in 'Tls10','Tls11' }
if ($weak) {
Write-Warning "Weak TLS negotiated on $($weak.Count) endpoint/protocol combinations."
$weak | Format-Table Endpoint,Protocol,Cipher
} else {
Write-Host "[PASS] No TLS 1.0/1.1 negotiation succeeded on any tested endpoint." -ForegroundColor Green
}
[PSCustomObject]@{
file = (Split-Path $jsonPath -Leaf)
sha256 = (Get-FileHash $jsonPath -Algorithm SHA256).Hash
generated_utc = $ts
control = '1.15'
operation = 'tls-posture-audit'
weak_findings = $weak.Count
} | ConvertTo-Json | Add-Content "$EvidencePath\manifest.jsonl"
5. Key Rotation (Routine — Non-Destructive)
Routine rotation creates a new key version in the existing Key Vault key. Customer Key automatically uses the latest enabled version on the next wrap.
<#
.SYNOPSIS
Creates a new version of an existing Customer Key wrapping key.
.DESCRIPTION
SAFE rotation: new version, both old and new versions remain enabled until Microsoft
confirms wrap on the new version. Does NOT delete or disable the previous version.
.EXAMPLE
.\Invoke-FsiKeyRotation.ps1 -VaultName kv-fsi-ck-eo-pri-eastus -KeyName m365-ck-key -EvidencePath .\evidence -WhatIf
#>
[CmdletBinding(SupportsShouldProcess, ConfirmImpact='High')]
param(
[Parameter(Mandatory)] [string] $VaultName,
[Parameter(Mandatory)] [string] $KeyName,
[string] $EvidencePath = '.\evidence'
)
$ErrorActionPreference = 'Stop'
$ts = Get-Date -Format 'yyyyMMddTHHmmssZ'
New-Item -ItemType Directory -Force -Path $EvidencePath | Out-Null
Start-Transcript -Path "$EvidencePath\rotate-$VaultName-$KeyName-$ts.log" -IncludeInvocationHeader
$beforeVersions = Get-AzKeyVaultKey -VaultName $VaultName -Name $KeyName -IncludeVersions
$beforeVersions | ConvertTo-Json -Depth 5 | Set-Content "$EvidencePath\rotate-before-$ts.json"
if ($PSCmdlet.ShouldProcess("$VaultName/$KeyName", "Create new key version")) {
$new = Add-AzKeyVaultKey -VaultName $VaultName -Name $KeyName -Destination HSM
Write-Host "New version created: $($new.Version)" -ForegroundColor Green
}
$after = Get-AzKeyVaultKey -VaultName $VaultName -Name $KeyName -IncludeVersions
$after | ConvertTo-Json -Depth 5 | Set-Content "$EvidencePath\rotate-after-$ts.json"
Stop-Transcript
Write-Host "Do NOT disable the previous version until Microsoft confirms wrap on the new version." -ForegroundColor Yellow
Write-Host "Verify with: (Get-DataEncryptionPolicy <name>).PrimaryKeyVaultUri -- should reflect new version after wrap." -ForegroundColor Yellow
6. Customer Key Revocation — Break-Glass Only
This is a destructive break-glass procedure
The script below is deliberately not provided. Customer Key revocation triggers an irreversible data-purge path. Microsoft cannot recover data after the purge window completes. Revocation must follow your CISO-approved break-glass runbook with named dual-control approvers; it is not a routine PowerShell operation. Your runbook should:
- Require explicit CISO + Records Officer + Legal sign-off (recorded in your change-management system)
- Be rehearsed in a non-production tenant first
- Document the data-purge timeline and the workloads it affects
- Confirm there is no business-continuity expectation for the data being purged
- Use Microsoft Support for the actual cmdlet sequence (
Set-DataEncryptionPolicy -Refresh, key disable/delete, support ticket gating)
Refer to Microsoft Learn: Manage Customer Key — revoke and start the data purge path.
7. End-to-End Validation
<#
.SYNOPSIS
Read-only validation of all configured Customer Key DEPs and vault hardening.
#>
param([string] $EvidencePath = '.\evidence')
$ErrorActionPreference = 'Continue'
$ts = Get-Date -Format 'yyyyMMddTHHmmssZ'
New-Item -ItemType Directory -Force -Path $EvidencePath | Out-Null
Write-Host "=== Control 1.15 — Customer Key Validation ===" -ForegroundColor Cyan
Connect-ExchangeOnline -ShowBanner:$false
$eoDeps = Get-M365DataAtRestEncryptionPolicy -ErrorAction SilentlyContinue
$workloadDeps = Get-DataEncryptionPolicy -ErrorAction SilentlyContinue
foreach ($d in @($eoDeps + $workloadDeps | Where-Object { $_ })) {
$marker = if ($d.PolicyState -eq 'Active' -or $d.State -eq 'Active') { 'PASS' } else { 'INVESTIGATE' }
$color = if ($marker -eq 'PASS') { 'Green' } else { 'Yellow' }
$state = if ($d.PolicyState) { $d.PolicyState } else { $d.State }
Write-Host ("[{0}] DEP '{1}' state={2}" -f $marker, $d.Name, $state) -ForegroundColor $color
}
Disconnect-ExchangeOnline -Confirm:$false
# Vault hardening
$vaults = Get-AzKeyVault | Where-Object { $_.VaultName -like 'kv-*ck*' }
foreach ($v in $vaults) {
$detail = Get-AzKeyVault -VaultName $v.VaultName -ResourceGroupName $v.ResourceGroupName
$sd = $detail.EnableSoftDelete
$pp = $detail.EnablePurgeProtection
$marker = if ($sd -and $pp) { 'PASS' } else { 'FAIL' }
Write-Host ("[{0}] Vault '{1}' soft-delete={2} purge-protection={3}" -f $marker, $v.VaultName, $sd, $pp) -ForegroundColor (if ($marker -eq 'PASS') { 'Green' } else { 'Red' })
}
# Emit evidence
$report = @{
eo_deps = $eoDeps
workload_deps = $workloadDeps
vaults = $vaults | ForEach-Object { Get-AzKeyVault -VaultName $_.VaultName -ResourceGroupName $_.ResourceGroupName }
timestamp_utc = $ts
}
$jsonPath = "$EvidencePath\validate-control-1.15-$ts.json"
$report | ConvertTo-Json -Depth 8 | Set-Content $jsonPath
[PSCustomObject]@{
file = (Split-Path $jsonPath -Leaf)
sha256 = (Get-FileHash $jsonPath -Algorithm SHA256).Hash
generated_utc = $ts
control = '1.15'
operation = 'validate'
} | ConvertTo-Json | Add-Content "$EvidencePath\manifest.jsonl"
Back to Control 1.15 | Portal Walkthrough | Verification Testing | Troubleshooting