Skip to content

Commit 1de4713

Browse files
Add-DbaComputerCertificate, Start/Stop-DbaDbEncryption - Fix PFX certificate chain import and improve parallel encryption handling (#9936)
1 parent 245d6ed commit 1de4713

File tree

6 files changed

+208
-109
lines changed

6 files changed

+208
-109
lines changed

dbatools.psm1

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -184,16 +184,6 @@ if (-not (Test-Path -Path "$script:PSModuleRoot\dbatools.dat") -or $script:seria
184184

185185
Write-ImportTime -Text "Loading internal commands via dotsource"
186186

187-
# Load replication libraries before dot-sourcing functions with replication type constraints
188-
# This is required for Remove-DbaReplArticle and Remove-DbaReplPublication which have
189-
# [Microsoft.SqlServer.Replication.*] type constraints in their parameters
190-
try {
191-
Add-ReplicationLibrary -ErrorAction SilentlyContinue
192-
} catch {
193-
# Silently ignore if replication libraries can't be loaded
194-
# The individual commands will handle this when they're executed
195-
}
196-
197187
# All exported functions
198188
foreach ($file in (Get-ChildItem -Path "$script:PSModuleRoot/public/" -Recurse -Filter *.ps1)) {
199189
. $file.FullName

public/Add-DbaComputerCertificate.ps1

Lines changed: 90 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -140,20 +140,54 @@ function Add-DbaComputerCertificate {
140140
}
141141

142142
Write-Message -Level Verbose -Message "Flags: $flags"
143+
144+
# Track if we're dealing with a certificate collection from a file
145+
$isCollection = $false
146+
$collectionData = $null
147+
143148
if ($Path) {
144149
if (-not (Test-Path -Path $Path)) {
145150
Stop-Function -Message "Path ($Path) does not exist." -Category InvalidArgument
146151
return
147152
}
148153

149154
try {
150-
# local has to be exportable to export to remote
151-
$bytes = [System.IO.File]::ReadAllBytes($Path)
155+
# Read file bytes and import locally to get certificate collection
156+
$fileBytes = [System.IO.File]::ReadAllBytes($Path)
157+
152158
# Use X509Certificate2Collection to import the full certificate chain
153159
$certCollection = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2Collection
154-
$null = $certCollection.Import($bytes, $SecurePassword, "Exportable, PersistKeySet")
155-
# Convert collection to array for processing
156-
$Certificate = @($certCollection)
160+
161+
# Handle password conversion for password-protected certificates (PFX files)
162+
$plainPassword = $null
163+
$ptr = [IntPtr]::Zero
164+
165+
if ($SecurePassword) {
166+
# Convert SecureString to plain text password for import/export operations
167+
# Using plain text for both Import() and Export() in all PowerShell versions
168+
# This is standard practice for .NET certificate operations
169+
$ptr = [System.Runtime.InteropServices.Marshal]::SecureStringToGlobalAllocUnicode($SecurePassword)
170+
$plainPassword = [System.Runtime.InteropServices.Marshal]::PtrToStringUni($ptr)
171+
}
172+
173+
try {
174+
# Import using plain text password (or null for non-password-protected certificates)
175+
# Works reliably in all PowerShell versions v3+
176+
$null = $certCollection.Import($fileBytes, $plainPassword, "Exportable, PersistKeySet")
177+
178+
# Export the entire collection as a single PFX to preserve the chain
179+
# This re-exports with the password, creating a fresh encrypted byte array that can be passed to remote
180+
$collectionData = $certCollection.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::PFX, $plainPassword)
181+
$isCollection = $true
182+
183+
# Still set $Certificate so the process block knows we have something to process
184+
$Certificate = @($certCollection)
185+
} finally {
186+
# Always clean up the plain text password from memory
187+
if ($ptr -ne [IntPtr]::Zero) {
188+
[System.Runtime.InteropServices.Marshal]::ZeroFreeGlobalAllocUnicode($ptr)
189+
}
190+
}
157191
} catch {
158192
Stop-Function -Message "Can't import certificate." -ErrorRecord $_
159193
return
@@ -164,15 +198,15 @@ function Add-DbaComputerCertificate {
164198
$scriptBlock = {
165199
param (
166200
$CertificateData,
167-
[SecureString]$SecurePassword,
201+
[string]$PlainPassword,
168202
$Store,
169203
$Folder,
170204
$flags
171205
)
172206

173207
# Use X509Certificate2Collection to import the full certificate chain
174208
$certCollection = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2Collection
175-
$certCollection.Import($CertificateData, $SecurePassword, $flags)
209+
$certCollection.Import($CertificateData, $PlainPassword, $flags)
176210

177211
Write-Verbose -Message "Importing certificate chain to $Folder\$Store using flags: $flags"
178212
$tempStore = New-Object System.Security.Cryptography.X509Certificates.X509Store($Folder, $Store)
@@ -201,26 +235,62 @@ function Add-DbaComputerCertificate {
201235
return
202236
}
203237

204-
foreach ($cert in $Certificate) {
205-
try {
206-
$certData = $cert.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::PFX, $SecurePassword)
207-
} catch {
208-
Stop-Function -Message "Can't export certificate" -ErrorRecord $_ -Continue
209-
}
238+
# Convert SecureString to plain text for passing to remote scriptblock
239+
# (PowerShell remoting encrypts the connection, so this is safe)
240+
$plainPassword = $null
241+
$ptr = [IntPtr]::Zero
242+
243+
if ($SecurePassword) {
244+
$ptr = [System.Runtime.InteropServices.Marshal]::SecureStringToGlobalAllocUnicode($SecurePassword)
245+
$plainPassword = [System.Runtime.InteropServices.Marshal]::PtrToStringUni($ptr)
246+
}
210247

211-
foreach ($computer in $ComputerName) {
212-
if ($PSCmdlet.ShouldProcess("$computer", "Attempting to import cert")) {
213-
if ($flags -contains "UserProtected" -and -not $computer.IsLocalHost) {
214-
Stop-Function -Message "UserProtected flag is only valid for localhost because it causes a prompt, skipping for $computer" -Continue
248+
try {
249+
# If we have a collection from a file, import it as a single unit to preserve the chain
250+
if ($isCollection -and $collectionData) {
251+
foreach ($computer in $ComputerName) {
252+
if ($PSCmdlet.ShouldProcess("$computer", "Attempting to import cert collection")) {
253+
if ($flags -contains "UserProtected" -and -not $computer.IsLocalHost) {
254+
Stop-Function -Message "UserProtected flag is only valid for localhost because it causes a prompt, skipping for $computer" -Continue
255+
}
256+
try {
257+
Invoke-Command2 -ComputerName $computer -Credential $Credential -ArgumentList $collectionData, $plainPassword, $Store, $Folder, $flags -ScriptBlock $scriptBlock -ErrorAction Stop |
258+
Select-DefaultView -Property FriendlyName, DnsNameList, Thumbprint, NotBefore, NotAfter, Subject, Issuer
259+
} catch {
260+
Stop-Function -Message "Failure" -ErrorRecord $_ -Target $computer -Continue
261+
}
215262
}
263+
}
264+
} else {
265+
# Handle individual certificates from pipeline
266+
foreach ($cert in $Certificate) {
216267
try {
217-
Invoke-Command2 -ComputerName $computer -Credential $Credential -ArgumentList $certdata, $SecurePassword, $Store, $Folder, $flags -ScriptBlock $scriptBlock -ErrorAction Stop |
218-
Select-DefaultView -Property FriendlyName, DnsNameList, Thumbprint, NotBefore, NotAfter, Subject, Issuer
268+
# Export requires plain text password
269+
$certData = $cert.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::PFX, $plainPassword)
219270
} catch {
220-
Stop-Function -Message "Failure" -ErrorRecord $_ -Target $computer -Continue
271+
Stop-Function -Message "Can't export certificate" -ErrorRecord $_ -Continue
272+
}
273+
274+
foreach ($computer in $ComputerName) {
275+
if ($PSCmdlet.ShouldProcess("$computer", "Attempting to import cert")) {
276+
if ($flags -contains "UserProtected" -and -not $computer.IsLocalHost) {
277+
Stop-Function -Message "UserProtected flag is only valid for localhost because it causes a prompt, skipping for $computer" -Continue
278+
}
279+
try {
280+
Invoke-Command2 -ComputerName $computer -Credential $Credential -ArgumentList $certdata, $plainPassword, $Store, $Folder, $flags -ScriptBlock $scriptBlock -ErrorAction Stop |
281+
Select-DefaultView -Property FriendlyName, DnsNameList, Thumbprint, NotBefore, NotAfter, Subject, Issuer
282+
} catch {
283+
Stop-Function -Message "Failure" -ErrorRecord $_ -Target $computer -Continue
284+
}
285+
}
221286
}
222287
}
223288
}
289+
} finally {
290+
# Always clean up the plain text password from memory
291+
if ($ptr -ne [IntPtr]::Zero) {
292+
[System.Runtime.InteropServices.Marshal]::ZeroFreeGlobalAllocUnicode($ptr)
293+
}
224294
}
225295
}
226296
}

public/Start-DbaDbEncryption.ps1

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -480,12 +480,12 @@ function Start-DbaDbEncryption {
480480
$SqlCredential
481481
)
482482

483+
$server = $null
483484
try {
484485
# Create new connection for this thread
485486
$splatConnection = @{
486-
SqlInstance = $ServerName
487-
SqlCredential = $SqlCredential
488-
EnableException = $true
487+
SqlInstance = $ServerName
488+
SqlCredential = $SqlCredential
489489
}
490490
$server = Connect-DbaInstance @splatConnection
491491
$db = $server.Databases[$DatabaseName]
@@ -496,11 +496,11 @@ function Start-DbaDbEncryption {
496496

497497
# Create encryption key if needed
498498
if (-not $db.HasDatabaseEncryptionKey) {
499-
$null = $db | New-DbaDbEncryptionKey -EncryptorName $EncryptorName -EnableException:$true
499+
$null = $db | New-DbaDbEncryptionKey -EncryptorName $EncryptorName -EnableException:$true -Confirm:$false
500500
}
501501

502502
# Enable encryption
503-
$result = $db | Enable-DbaDbEncryption -EncryptorName $EncryptorName
503+
$result = $db | Enable-DbaDbEncryption -EncryptorName $EncryptorName -Confirm:$false
504504

505505
[PSCustomObject]@{
506506
ComputerName = $server.ComputerName
@@ -521,6 +521,10 @@ function Start-DbaDbEncryption {
521521
Status = "Failed"
522522
Error = $_.Exception.Message
523523
}
524+
} finally {
525+
if ($server) {
526+
Disconnect-DbaInstance -SqlInstance $server
527+
}
524528
}
525529
}
526530

@@ -573,10 +577,6 @@ function Start-DbaDbEncryption {
573577
$result = $thread.Thread.EndInvoke($thread.Handle)
574578
$thread.IsRetrieved = $true
575579

576-
if ($thread.Thread.HadErrors) {
577-
Stop-Function -Message "Problem enabling encryption for $($thread.Database) on $($thread.Instance)" -ErrorRecord $thread.Thread.Streams.Error -Continue
578-
}
579-
580580
if ($result) {
581581
if ($result.Status -eq "Failed") {
582582
Stop-Function -Message "Failed to enable encryption for $($result.DatabaseName) on $($result.SqlInstance): $($result.Error)" -Continue

public/Stop-DbaDbEncryption.ps1

Lines changed: 58 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -98,64 +98,79 @@ function Stop-DbaDbEncryption {
9898
# Parallel processing using runspaces
9999
$disableScript = {
100100
param (
101-
$Database,
101+
$ServerName,
102+
$DatabaseName,
103+
$SqlCredential,
102104
$EnableException
103105
)
106+
$server = $null
104107
try {
105-
if ($Database.EncryptionEnabled) {
106-
# Create a new connection to avoid threading issues
107-
$connString = $Database.Parent.ConnectionContext.ConnectionString
108-
$server = New-Object Microsoft.SqlServer.Management.Smo.Server $connString
109-
$db = $server.Databases[$Database.Name]
110-
111-
if ($db.EncryptionEnabled) {
112-
# Disable encryption
113-
$db.EncryptionEnabled = $false
114-
$db.Alter()
115-
116-
# Wait for decryption to complete
117-
do {
118-
Start-Sleep -Seconds 1
119-
$db.Refresh()
120-
} while ($db.EncryptionEnabled)
121-
122-
# Drop the Database Encryption Key
123-
if ($db.HasDatabaseEncryptionKey) {
124-
$db.DatabaseEncryptionKey.Drop()
125-
}
108+
# Create new connection for this thread
109+
$splatConnection = @{
110+
SqlInstance = $ServerName
111+
SqlCredential = $SqlCredential
112+
}
113+
$server = Connect-DbaInstance @splatConnection
114+
$db = $server.Databases[$DatabaseName]
115+
116+
if (-not $db) {
117+
throw "Database $DatabaseName not found on $ServerName"
118+
}
126119

120+
if ($db.EncryptionEnabled) {
121+
# Disable encryption
122+
$db.EncryptionEnabled = $false
123+
$db.Alter()
124+
125+
# Wait for decryption to complete
126+
$timeout = 120
127+
$elapsed = 0
128+
do {
129+
Start-Sleep -Seconds 1
130+
$elapsed++
127131
$db.Refresh()
128-
[PSCustomObject]@{
129-
ComputerName = $db.Parent.ComputerName
130-
InstanceName = $db.Parent.ServiceName
131-
SqlInstance = $db.Parent.DomainInstanceName
132-
DatabaseName = $db.Name
133-
EncryptionEnabled = $db.EncryptionEnabled
134-
Status = "Success"
135-
Error = $null
136-
}
132+
} while ($db.EncryptionEnabled -and $elapsed -lt $timeout)
133+
134+
# Drop the Database Encryption Key
135+
if ($db.HasDatabaseEncryptionKey) {
136+
$db.DatabaseEncryptionKey.Drop()
137+
}
138+
139+
$db.Refresh()
140+
[PSCustomObject]@{
141+
ComputerName = $server.ComputerName
142+
InstanceName = $server.ServiceName
143+
SqlInstance = $server.DomainInstanceName
144+
DatabaseName = $DatabaseName
145+
EncryptionEnabled = $db.EncryptionEnabled
146+
Status = "Success"
147+
Error = $null
137148
}
138149
} else {
139150
[PSCustomObject]@{
140-
ComputerName = $Database.Parent.ComputerName
141-
InstanceName = $Database.Parent.ServiceName
142-
SqlInstance = $Database.Parent.DomainInstanceName
143-
DatabaseName = $Database.Name
151+
ComputerName = $server.ComputerName
152+
InstanceName = $server.ServiceName
153+
SqlInstance = $server.DomainInstanceName
154+
DatabaseName = $DatabaseName
144155
EncryptionEnabled = $false
145156
Status = "NotEncrypted"
146157
Error = $null
147158
}
148159
}
149160
} catch {
150161
[PSCustomObject]@{
151-
ComputerName = $Database.Parent.ComputerName
152-
InstanceName = $Database.Parent.ServiceName
153-
SqlInstance = $Database.Parent.DomainInstanceName
154-
DatabaseName = $Database.Name
155-
EncryptionEnabled = $Database.EncryptionEnabled
162+
ComputerName = $null
163+
InstanceName = $null
164+
SqlInstance = $ServerName
165+
DatabaseName = $DatabaseName
166+
EncryptionEnabled = $null
156167
Status = "Failed"
157168
Error = $_.Exception.Message
158169
}
170+
} finally {
171+
if ($server) {
172+
Disconnect-DbaInstance -SqlInstance $server
173+
}
159174
}
160175
}
161176

@@ -172,7 +187,9 @@ function Stop-DbaDbEncryption {
172187

173188
foreach ($db in $InputObject) {
174189
$splatRunspace = @{
175-
Database = $db
190+
ServerName = $db.Parent.Name
191+
DatabaseName = $db.Name
192+
SqlCredential = $SqlCredential
176193
EnableException = $EnableException
177194
}
178195

@@ -205,10 +222,6 @@ function Stop-DbaDbEncryption {
205222
$result = $thread.Thread.EndInvoke($thread.Handle)
206223
$thread.IsRetrieved = $true
207224

208-
if ($thread.Thread.HadErrors) {
209-
Stop-Function -Message "Problem disabling encryption for $($thread.Database) on $($thread.Instance)" -ErrorRecord $thread.Thread.Streams.Error -Continue
210-
}
211-
212225
if ($result) {
213226
if ($result.Status -eq "Failed") {
214227
Stop-Function -Message "Failed to disable encryption for $($result.DatabaseName) on $($result.SqlInstance): $($result.Error)" -Continue

0 commit comments

Comments
 (0)