PowerShell 技能连载 - 凭据安全管理

适用于 PowerShell 7.0 及以上版本

在自动化脚本中硬编码密码是最常见也最危险的安全隐患之一。一段包含明文密码的脚本一旦被提交到版本控制系统,就会永久留在 Git 历史中,即使后续删除也无济于事。据统计,GitHub 上每天有数千个包含泄露凭据的提交被安全研究员发现。对于企业运维团队来说,一个泄露的服务账号密码可能意味着整个基础设施面临风险。

PowerShell 提供了完善的凭据安全管理机制。从传统的 PSCredential + SecureString 方案,到现代化的 SecretManagement 模块生态,再到凭据轮换与审计的完整生命周期管理,PowerShell 生态已经覆盖了凭据安全的各个环节。合理运用这些工具,可以确保密码永远不会以明文形式出现在脚本、日志或配置文件中。

本文将从三个维度展开:使用 PSCredential 和安全字符串进行本地凭据保护;通过 SecretManagement 模块实现跨平台密钥管理;以及构建凭据轮换与审计机制,形成完整的凭据安全闭环。

PSCredential 与安全字符串

PSCredential 是 PowerShell 中封装用户名和密码的标准对象,密码在内部以 SecureString 形式存储,不会以明文暴露在内存中。结合 Windows 的 DPAPI(数据保护 API),可以将凭据安全地序列化到文件,供后续脚本复用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# 方式一:交互式创建凭据(适合手动执行场景)
$cred = Get-Credential -Message '请输入服务账号凭据'

# 方式二:通过代码创建凭据(适合自动化场景)
$plainPassword = 'My$ecureP@ssw0rd!2026'
$securePassword = $plainPassword | ConvertTo-SecureString -AsPlainText -Force
$cred = [System.Management.Automation.PSCredential]::new('svcautomation', $securePassword)

# 将凭据安全导出到文件(DPAPI 加密,仅当前用户可解密)
$cred | Export-Clixml -Path './svcautomation.cred.xml'

# 从文件导入凭据
$loadedCred = Import-Clixml -Path './svcautomation.cred.xml'

# 使用 SecureString 的 DPAPI 加密/解密
$encrypted = $plainPassword | ConvertTo-SecureString -AsPlainText -Force |
ConvertFrom-SecureString

# 将加密字符串保存到文件
$encrypted | Set-Content -Path './encrypted-password.txt'

# 在另一台机器或用户上下文中解密(需配合 Key 参数使用 AES 对称加密)
$key = (1..16 | ForEach-Object { Get-Random -Minimum 0 -Maximum 255 })
$encryptedWithKey = $plainPassword | ConvertTo-SecureString -AsPlainText -Force |
ConvertFrom-SecureString -Key $key

Write-Host "用户名: $($loadedCred.UserName)"
Write-Host "密码长度: $($loadedCred.GetNetworkCredential().Password.Length) 个字符"
Write-Host "加密字符串前 40 字符: $($encrypted.Substring(0, 40))..."

执行结果示例:

1
2
3
用户名: svcautomation
密码长度: 20 个字符
加密字符串前 40 字符: 01000000d08c9ddf0115d1118c7a00c04f...

需要注意的是,不带 -Key 参数的 ConvertFrom-SecureString 使用 Windows DPAPI 加密,加密后的数据只能在同一台机器的同一用户账户下解密。如果需要跨机器传输,必须使用 -Key 参数指定 AES 密钥。

SecretManagement 模块

Microsoft.PowerShell.SecretManagement 是微软官方推出的密钥管理框架,采用”核心模块 + 扩展保管库”的架构。核心模块提供统一的读写接口,而实际的密钥存储由扩展库实现,支持 Windows Credential Manager、Azure Key Vault、KeePass、HashiCorp Vault 等多种后端。脚本代码面向统一 API 编写,不必关心底层存储细节。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# 安装核心模块和扩展保管库
Install-Module -Name Microsoft.PowerShell.SecretManagement -Force -Scope CurrentUser
Install-Module -Name Microsoft.PowerShell.SecretStore -Force -Scope CurrentUser

# 注册本地 SecretStore 保管库
Register-SecretVault -Name 'LocalDevVault' -ModuleName Microsoft.PowerShell.SecretStore

# 配置保管库策略(需要密码才能访问)
Set-SecretStoreConfiguration -Authentication Password -Interaction None

# 存储各种类型的密钥
Set-Secret -Name 'DatabaseConnection' -Secret 'Server=db01;User=app;Password=S3cure!;' -Vault LocalDevVault
Set-Secret -Name 'ApiKey' -Secret 'sk-proj-abc123def456ghi789' -Vault LocalDevVault
Set-Secret -Name 'ServiceAccount' -Secret (
[System.Management.Automation.PSCredential]::new(
'DOMAIN\svcautomation',
('P@ssw0rd!' | ConvertTo-SecureString -AsPlainText -Force)
)
) -Vault LocalDevVault

# 读取密钥
$dbConn = Get-Secret -Name 'DatabaseConnection' -Vault LocalDevVault
$apiKey = Get-Secret -Name 'ApiKey' -Vault LocalDevVault
$svcCred = Get-Secret -Name 'ServiceAccount' -Vault LocalDevVault

# 列出所有密钥名称(不暴露值)
Get-SecretInfo -Vault LocalDevVault | Format-Table Name, Type

# 删除不需要的密钥
Remove-Secret -Name 'ApiKey' -Vault LocalDevVault

# 查看已注册的保管库信息
Get-SecretVault | Format-Table Name, ModuleName, IsDefault

执行结果示例:

1
2
3
4
5
6
7
8
9
Name                Type
---- ----
DatabaseConnection SecureString
ApiKey SecureString
ServiceAccount PSCredential

Name ModuleName IsDefault
---- ---------- ---------
LocalDevVault Microsoft.PowerShell.SecretStore False

SecretManagement 的优势在于扩展性。如果未来需要从本地文件迁移到 Azure Key Vault 或 HashiCorp Vault,只需注册新的保管库并迁移数据,业务脚本中的 Get-Secret 调用完全不需要修改。

凭据轮换与审计

凭据安全不止于安全存储,还需要定期轮换密码、记录使用日志并设置过期告警。以下代码构建了一个简易的凭据生命周期管理系统,可以自动生成强密码、更新保管库中的密钥,并记录完整的审计日志。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
# 凭据轮换与审计管理系统

# 生成强随机密码
function New-RandomPassword {
[CmdletBinding()]
[OutputType([string])]
param(
[int]$Length = 24,
[int]$NonAlphaNumeric = 6
)
$assembly = [System.Reflection.Assembly]::LoadWithPartialName('System.Web')
$password = [System.Web.Security.Membership]::GeneratePassword($Length, $NonAlphaNumeric)
return $password
}

# 凭据审计日志(追加式 JSON 日志)
function Write-CredentialAuditLog {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$SecretName,
[Parameter(Mandatory)]
[ValidateSet('Create', 'Read', 'Rotate', 'Delete')]
[string]$Action,
[string]$VaultName = 'LocalDevVault',
[string]$Operator = $env:USERNAME
)
$logEntry = [ordered]@{
Timestamp = Get-Date -Format 'yyyy-MM-ddTHH:mm:ss.fffZ'
Action = $Action
SecretName = $SecretName
VaultName = $VaultName
Operator = $Operator
Machine = $env:COMPUTERNAME
}
$logPath = './credential-audit.json'
$existing = @()
if (Test-Path $logPath) {
$existing = Get-Content $logPath -Raw | ConvertFrom-Json
if ($existing -isnot [array]) { $existing = @($existing) }
}
$existing += $logEntry
$existing | ConvertTo-Json -Depth 5 | Set-Content $logPath -Encoding UTF8
}

# 凭据轮换:生成新密码并更新保管库
function Invoke-CredentialRotation {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$SecretName,
[string]$VaultName = 'LocalDevVault'
)
$newPassword = New-RandomPassword -Length 32 -NonAlphaNumeric 8
$secureNew = $newPassword | ConvertTo-SecureString -AsPlainText -Force
Set-Secret -Name $SecretName -Secret $secureNew -Vault $VaultName
Write-CredentialAuditLog -SecretName $SecretName -Action Rotate -VaultName $VaultName
Write-Host "[$SecretName] 密码已轮换,新密码长度: $($newPassword.Length) 字符"
}

# 检查凭据是否即将过期(基于审计日志中的最后轮换时间)
function Test-CredentialExpiry {
[CmdletBinding()]
param(
[int]$ExpireDays = 90
)
$logPath = './credential-audit.json'
if (-not (Test-Path $logPath)) {
Write-Warning '审计日志文件不存在,无法检查凭据过期状态'
return
}
$logs = Get-Content $logPath -Raw | ConvertFrom-Json
$rotateLogs = $logs | Where-Object { $_.Action -eq 'Rotate' }
$latestRotations = $rotateLogs | Group-Object SecretName | ForEach-Object {
$latest = ($_.Group | Sort-Object Timestamp -Descending | Select-Object -First 1)
[PSCustomObject]@{
SecretName = $_.Name
LastRotated = [datetime]$latest.Timestamp
DaysSince = [math]::Floor((Get-Date) - [datetime]$latest.Timestamp)
ShouldRotate = ((Get-Date) - [datetime]$latest.Timestamp).TotalDays -gt $ExpireDays
}
}
$latestRotations | Format-Table SecretName, LastRotated, DaysSince, ShouldRotate
$expired = $latestRotations | Where-Object { $_.ShouldRotate }
if ($expired) {
Write-Warning "以下凭据已超过 $ExpireDays 天未轮换:$($expired.SecretName -join ', ')"
}
}

# 执行轮换并记录审计日志
Invoke-CredentialRotation -SecretName 'DatabaseConnection' -VaultName LocalDevVault
Invoke-CredentialRotation -SecretName 'ServiceAccount' -VaultName LocalDevVault

# 检查凭据过期状态
Test-CredentialExpiry -ExpireDays 90

执行结果示例:

1
2
3
4
5
6
7
[DatabaseConnection] 密码已轮换,新密码长度: 32 字符
[ServiceAccount] 密码已轮换,新密码长度: 32 字符

SecretName LastRotated DaysSince ShouldRotate
---------- ------------ --------- ------------
DatabaseConnection 2026-04-16T10:30:00.000Z 0 False
ServiceAccount 2026-04-16T10:30:01.000Z 0 False

注意事项

  • 绝不硬编码密码:脚本中出现的任何明文密码都是安全隐患,应始终通过 PSCredentialSecureStringSecretManagement 模块来管理凭据。
  • DPAPI 加密的局限性:不带 -Key 参数的 ConvertFrom-SecureString 使用 Windows DPAPI,加密数据绑定于当前机器和用户账户,无法跨机器解密。跨机器场景请使用 -Key 参数配合 AES 对称加密。
  • SecretStore 密码保护:生产环境中务必将 SecretStore 配置为需要密码访问(Set-SecretStoreConfiguration -Authentication Password),不要使用 None 模式,否则任何能登录的用户都能读取密钥。
  • 审计日志的存储安全:凭据审计日志本身也包含敏感操作记录,应存放在受保护的位置,并设置适当的 NTFS 权限或文件加密,防止未授权访问。
  • 定期轮换密码:建议建立密码轮换策略,至少每 90 天轮换一次服务账号密码。轮换后确保所有依赖该凭据的自动化任务都使用更新后的密钥。
  • 密钥传输安全:在 CI/CD 管线中使用密钥时,应通过安全的管线变量或环境变量传递,避免将密钥写入磁盘文件或打印到控制台输出中。

PowerShell 技能连载 - 加密与数据保护

适用于 PowerShell 5.1 及以上版本

在现代运维和自动化场景中,敏感数据几乎无处不在——数据库连接字符串、API 密钥、用户个人信息、财务记录等。如果这些数据以明文形式存储或传输,一旦系统被入侵或日志泄露,后果将不堪设想。加密(Encryption)是保护数据机密性的最后一道防线。

PowerShell 依托 .NET Framework / .NET 的 System.Security.Cryptography 命名空间,提供了从对称加密、非对称加密到哈希校验、数字签名的完整加密工具链。无论是在脚本中保护凭据、验证文件完整性,还是对发布脚本进行代码签名,这些能力都是编写安全自动化脚本的基础。

本文将通过三个实战场景,分别演示如何使用 AES 对称加密保护文件和字符串、利用哈希算法验证数据完整性,以及通过数字证书实现代码签名与验证。

对称加密与文件保护

对称加密使用同一把密钥进行加密和解密,适合对大量数据进行快速加解密。AES(Advanced Encryption Standard)是目前最广泛使用的对称加密算法,密钥长度支持 128/192/256 位。以下脚本封装了 AES 加密和解密的核心逻辑,并提供了文件级别的加密保护功能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
# AES 对称加密工具函数
function New-AesKey {
param([int]$KeySize = 256)
$aes = [System.Security.Cryptography.Aes]::Create()
$aes.KeySize = $KeySize
$aes.GenerateKey()
$aes.GenerateIV()
return @{
Key = $aes.Key
IV = $aes.IV
}
}

function Protect-StringWithAes {
param(
[Parameter(Mandatory)][string]$PlainText,
[Parameter(Mandatory)][byte[]]$Key,
[Parameter(Mandatory)][byte[]]$IV
)
$aes = [System.Security.Cryptography.Aes]::Create()
$aes.Key = $Key
$aes.IV = $IV
$encryptor = $aes.CreateEncryptor()
$plainBytes = [System.Text.Encoding]::UTF8.GetBytes($PlainText)
$encryptedBytes = $encryptor.TransformFinalBlock($plainBytes, 0, $plainBytes.Length)
return [Convert]::ToBase64String($encryptedBytes)
}

function Unprotect-StringWithAes {
param(
[Parameter(Mandatory)][string]$CipherText,
[Parameter(Mandatory)][byte[]]$Key,
[Parameter(Mandatory)][byte[]]$IV
)
$aes = [System.Security.Cryptography.Aes]::Create()
$aes.Key = $Key
$aes.IV = $IV
$decryptor = $aes.CreateDecryptor()
$cipherBytes = [Convert]::FromBase64String($CipherText)
$decryptedBytes = $decryptor.TransformFinalBlock($cipherBytes, 0, $cipherBytes.Length)
return [System.Text.Encoding]::UTF8.GetString($decryptedBytes)
}

# 生成密钥对
$keyInfo = New-AesKey -KeySize 256
Write-Host "AES 密钥长度: $($keyInfo.Key.Length * 8) 位"
Write-Host "初始化向量长度: $($keyInfo.IV.Length * 8) 位"

# 加密敏感字符串
$secret = "Server=db.prod.example.com;User=admin;Password=S3cretP@ss!"
$encrypted = Protect-StringWithAes -PlainText $secret -Key $keyInfo.Key -IV $keyInfo.IV
Write-Host "`n加密结果: $encrypted"

# 解密还原
$decrypted = Unprotect-StringWithAes -CipherText $encrypted -Key $keyInfo.Key -IV $keyInfo.IV
Write-Host "解密结果: $decrypted"

# 将密钥安全存储到文件(仅当前用户可访问)
$keyPath = "$env:USERPROFILE\.secrets\aes.key"
$ivPath = "$env:USERPROFILE\.secrets\aes.iv"
New-Item -ItemType Directory -Path (Split-Path $keyPath) -Force | Out-Null
[System.IO.File]::WriteAllBytes($keyPath, $keyInfo.Key)
[System.IO.File]::WriteAllBytes($ivPath, $keyInfo.IV)
Write-Host "`n密钥已保存至 $keyPath"

执行结果示例:

1
2
3
4
5
6
7
8
AES 密钥长度: 256
初始化向量长度: 128

加密结果: q8Jx3LmN9vT2kR5wY8aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789abcdefg==

解密结果: Server=db.prod.example.com;User=admin;Password=S3cretP@ss!

密钥已保存至 C:\Users\admin\.secrets\aes.key

哈希与完整性验证

哈希算法将任意长度的数据映射为固定长度的摘要值,具有单向性和抗碰撞性,广泛用于数据完整性校验和消息认证。SHA-256 和 SHA-512 是目前推荐的哈希算法。HMAC(Hash-based Message Authentication Code)则在此基础上加入密钥,既能验证完整性又能验证消息来源。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
# 文件哈希计算与完整性校验
function Get-FileHashEx {
param(
[Parameter(Mandatory)][string]$Path,
[ValidateSet('SHA256', 'SHA384', 'SHA512', 'MD5')]
[string]$Algorithm = 'SHA256'
)
if (-not (Test-Path $Path)) {
throw "文件不存在: $Path"
}
$hashAlg = [System.Security.Cryptography.HashAlgorithm]::Create($Algorithm)
$fileStream = [System.IO.File]::OpenRead((Resolve-Path $Path).Path)
try {
$hashBytes = $hashAlg.ComputeHash($fileStream)
$hashHex = [BitConverter]::ToString($hashBytes) -replace '-', ''
return @{
Path = $Path
Algorithm = $Algorithm
Hash = $hashHex
Size = $fileStream.Length
}
}
finally {
$fileStream.Dispose()
}
}

# 计算文件哈希
$result = Get-FileHashEx -Path $PSCommandPath -Algorithm SHA256
Write-Host "文件: $($result.Path)"
Write-Host "算法: $($result.Algorithm)"
Write-Host "哈希: $($result.Hash)"
Write-Host "大小: $($result.Size) 字节"

# HMAC 消息认证码
function Get-HmacHash {
param(
[Parameter(Mandatory)][string]$Message,
[Parameter(Mandatory)][string]$Secret
)
$keyBytes = [System.Text.Encoding]::UTF8.GetBytes($Secret)
$messageBytes = [System.Text.Encoding]::UTF8.GetBytes($Message)
$hmac = New-Object System.Security.Cryptography.HMACSHA256 -ArgumentList @(,$keyBytes)
$hashBytes = $hmac.ComputeHash($messageBytes)
return [BitConverter]::ToString($hashBytes) -replace '-', ''
}

# 模拟 API 签名验证场景
$apiPayload = '{"action":"transfer","amount":5000,"to":"account-12345"}'
$apiSecret = "my-api-secret-key-2026"
$signature = Get-HmacHash -Message $apiPayload -Secret $apiSecret
Write-Host "`nAPI 载荷: $apiPayload"
Write-Host "HMAC 签名: $signature"

# 接收方验证签名
$receivedSig = Get-HmacHash -Message $apiPayload -Secret $apiSecret
$isValid = ($signature -eq $receivedSig)
Write-Host "签名验证: $(if ($isValid) { '通过' } else { '失败' })"

# 批量文件完整性基线
$baselineFile = "$env:TEMP\hash-baseline.csv"
$monitorPath = $PWD.Path
$files = Get-ChildItem -Path $monitorPath -Filter "*.ps1" -File
$baseline = foreach ($f in $files) {
$h = Get-FileHashEx -Path $f.FullName -Algorithm SHA256
[PSCustomObject]@{
File = $f.Name
SHA256 = $h.Hash
Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
}
}
$baseline | Export-Csv -Path $baselineFile -NoTypeInformation -Encoding UTF8
Write-Host "`n已生成 $($baseline.Count) 个文件的完整性基线: $baselineFile"

执行结果示例:

1
2
3
4
5
6
7
8
9
10
文件: C:\Scripts\audit.ps1
算法: SHA256
哈希: A1B2C3D4E5F6789012345678901234567890ABCDEF1234567890ABCDEF123456
大小: 4096 字节

API 载荷: {"action":"transfer","amount":5000,"to":"account-12345"}
HMAC 签名: 7F8E9D0C1B2A3948567E6F5D4C3B2A10987654321F0E1D2C3B4A59687012345
签名验证: 通过

已生成 12 个文件的完整性基线: C:\Users\admin\AppData\Local\Temp\hash-baseline.csv

数字签名与证书操作

数字签名结合了非对称加密和哈希算法,不仅能验证数据的完整性,还能确认数据的来源身份。在 PowerShell 中,代码签名是脚本安全策略的核心环节——只有受信任的发布者签名的脚本才能在受限执行策略下运行。此外,证书操作还广泛用于 TLS/SSL 通信和文档签名。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
# 查看本机代码签名证书
$codeCerts = Get-ChildItem -Path Cert:\CurrentUser\My -CodeSigningCert
if ($codeCerts) {
foreach ($cert in $codeCerts) {
Write-Host "证书主题: $($cert.Subject)"
Write-Host "指纹: $($cert.Thumbprint)"
Write-Host "有效期: $($cert.NotBefore) ~ $($cert.NotAfter)"
Write-Host "颁发者: $($cert.Issuer)"
Write-Host "---"
}
}
else {
Write-Host "未找到代码签名证书,创建自签名证书用于测试..."
$testCert = New-SelfSignedCertificate `
-Type CodeSigningCert `
-Subject "CN=PowerShell Test Signing" `
-CertStoreLocation "Cert:\CurrentUser\My" `
-NotAfter (Get-Date).AddYears(1)
Write-Host "已创建测试证书: $($testCert.Subject)"
Write-Host "指纹: $($testCert.Thumbprint)"
}

# 对脚本进行数字签名
function Set-ScriptSignature {
param(
[Parameter(Mandatory)][string]$ScriptPath,
[Parameter(Mandatory)][string]$CertThumbprint
)
$cert = Get-Item -Path "Cert:\CurrentUser\My\$CertThumbprint"
if (-not $cert) {
throw "未找到指纹为 $CertThumbprint 的证书"
}
$status = Get-AuthenticodeSignature -FilePath $ScriptPath
if ($status.Status -eq 'Valid') {
Write-Host "脚本已签名且签名有效: $ScriptPath"
return
}
Set-AuthenticodeSignature -FilePath $ScriptPath -Certificate $cert | Out-Null
$newStatus = Get-AuthenticodeSignature -FilePath $ScriptPath
Write-Host "签名状态: $($newStatus.Status)"
Write-Host "签名者: $($newStatus.SignerCertificate.Subject)"
}

# 证书链验证
function Test-CertificateChain {
param([Parameter(Mandatory)][string]$CertThumbprint)
$cert = Get-Item -Path "Cert:\CurrentUser\My\$CertThumbprint"
if (-not $cert) {
throw "未找到证书: $CertThumbprint"
}
$chain = New-Object System.Security.Cryptography.X509Certificates.X509Chain
$chain.ChainPolicy.RevocationMode = [System.Security.Cryptography.X509Certificates.X509RevocationMode]::Online
$chain.ChainPolicy.RevocationFlag = [System.Security.Cryptography.X509Certificates.X509RevocationFlag]::ExcludeRoot
$isValid = $chain.Build($cert)
Write-Host "证书: $($cert.Subject)"
Write-Host "链验证: $(if ($isValid) { '通过' } else { '失败' })"
if (-not $isValid) {
foreach ($element in $chain.ChainElements) {
Write-Host " 链节点: $($element.Certificate.Subject)"
}
foreach ($status in $chain.ChainStatus) {
Write-Host " 状态: $($status.StatusInformation)"
}
}
return $isValid
}

# 导出证书公钥(用于分发)
$pubCertPath = "$env:TEMP\PowerShellSigning.cer"
if ($testCert) {
Export-Certificate -Cert $testCert -FilePath $pubCertPath | Out-Null
Write-Host "`n公钥已导出至: $pubCertPath"
Write-Host "文件大小: $((Get-Item $pubCertPath).Length) 字节"
}

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
未找到代码签名证书,创建自签名证书用于测试...
已创建测试证书: CN=PowerShell Test Signing
指纹: A1B2C3D4E5F6789012345678901234567890ABCD

签名状态: Valid
签名者: CN=PowerShell Test Signing

证书: CN=PowerShell Test Signing
链验证: 通过

公钥已导出至: C:\Users\admin\AppData\Local\Temp\PowerShellSigning.cer
文件大小: 814 字节

注意事项

  1. 密钥管理是安全的基石:再强的加密算法,如果密钥管理不当(如硬编码在脚本中、存储在无权限控制的文件中),整个安全体系形同虚设。生产环境建议使用 Windows DPAPI(ConvertTo-SecureString)、Azure Key Vault 或 HashiCorp Vault 等专业密钥管理方案。

  2. 优先使用 AES 和 SHA-256 及以上算法:DES、3DES、MD5、SHA-1 等算法已被证实存在安全弱点,新项目应一律避免使用。AES-256 是对称加密的首选,SHA-256/SHA-512 是哈希的首选。

  3. 自签名证书仅用于测试:生产环境应使用来自受信任 CA(如企业 AD CS、Let’s Encrypt、DigiCert 等)颁发的证书。自签名证书无法被其他系统自动信任,且无法提供真正的身份验证保障。

  4. 加密数据时要同时保护 IV:AES 加密中 IV(初始化向量)不需要保密,但必须不可预测且每次加密都不同。如果重复使用相同的 Key 和 IV 加密不同数据,会大幅降低安全性。

  5. HMAC 密钥长度应与哈希输出长度匹配:HMAC-SHA256 的密钥至少应为 32 字节,HMAC-SHA512 的密钥至少应为 64 字节。过短的密钥会削弱消息认证的安全性。

  6. 注意 .NET 版本差异:部分加密 API 在 .NET Framework 和 .NET(Core)之间行为略有不同。例如 Aes.Create() 在两个运行时都可用,但某些过时的算法类(如 RijndaelManaged)在 .NET 6+ 中可能已标记为过时。建议始终使用 AesSHA256 等抽象工厂方法创建加密对象。

PowerShell 技能连载 - 安全字符串处理

适用于 PowerShell 5.1 及以上版本(Windows)

在自动化脚本中处理密码、API 密钥、连接字符串等敏感信息时,直接将明文写入脚本或配置文件是非常危险的做法。一旦代码仓库泄露或日志被不当收集,这些凭据就会暴露无遗。PowerShell 提供了 SecureString 类型,它使用 Windows 数据保护 API(DPAPI)对内存中的字符串进行加密,使得敏感数据不会以明文形式驻留在进程内存中。

虽然 SecureString 并非银弹——在 .NET Core 跨平台场景下其保护能力有所减弱——但在 Windows 环境中,结合 DPAPI 的用户/机器级密钥,它仍然是脚本开发中最实用的凭据保护手段之一。本文将系统介绍 SecureString 的创建、转换、持久化以及凭据对象的完整使用流程,帮助你在自动化场景中更安全地管理敏感信息。

创建 SecureString

SecureString 有多种创建方式,应根据使用场景选择合适的方法。对于交互式脚本,推荐使用 Read-Host 弹出提示让用户输入;对于自动化场景,可以从已加密的文件中还原。

下面的示例展示了三种常见的创建方式:

1
2
3
4
5
6
7
8
9
10
# 方式一:交互式输入(推荐,最安全)
$securePwd = Read-Host -Prompt "请输入密码" -AsSecureString

# 方式二:从明文转换(仅用于测试或临时场景)
$plainText = "MySecretPass123!"
$securePwd = $plainText | ConvertTo-SecureString -AsPlainText -Force

# 方式三:从已加密的字符串还原(适合自动化)
$encryptedString = Get-Content -Path "C:\Vault\pwd.enc.txt" -Raw
$securePwd = $encryptedString | ConvertTo-SecureString

执行结果示例:

1
2
3
请输入密码: ************

(使用 -AsSecureString 时,输入内容以星号显示,不会回显明文)

注意方式二使用了 -AsPlainText -Force 参数,它会将明文字符串直接转换为 SecureString。由于明文在转换前会短暂出现在内存中,这种方式仅适合开发测试,不建议在生产环境中使用。

持久化加密字符串

在自动化场景中,我们需要将密码保存到文件以便脚本无人值守运行。ConvertFrom-SecureString 可以将 SecureString 导出为加密后的十六进制字符串。默认情况下使用当前用户的 DPAPI 密钥加密,这意味着只有同一个 Windows 用户账户才能解密。

1
2
3
4
5
# 将密码安全地保存到文件
$cred = Get-Credential -Message "输入需要持久化的凭据"
$cred.Password | ConvertFrom-SecureString | Set-Content -Path "C:\Vault\encrypted_pwd.txt"

Write-Host "密码已加密保存到 C:\Vault\encrypted_pwd.txt"

执行结果示例:

1
2
3
4
5
6
7
8
9
cmdlet Get-Credential at command pipeline position 1
Supply values for the following parameters:
Message: 输入需要持久化的凭据

Windows PowerShell credential request.
输入需要持久化的凭据
User: admin
Password for user admin: ************
密码已加密保存到 C:\Vault\encrypted_pwd.txt

保存后的文件内容是一串十六进制字符,例如:

1
01000000d08c9ddf0115d1118c7a00c04fc297eb01000000...

这段密文绑定到当前 Windows 用户的 DPAPI 密钥,其他用户即使拿到文件也无法解密。

使用自定义密钥跨机器复用

默认的 DPAPI 加密方式仅限当前用户解密,如果需要在不同机器或不同用户之间共享加密凭据,可以使用自定义密钥(AES-256)进行加密。密钥本身需要通过安全的渠道分发(如密钥管理系统或环境变量),而不是硬编码在脚本中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 生成 32 字节(256 位)的随机密钥
$key = New-Object byte[] 32
$rng = [System.Security.Cryptography.RandomNumberGenerator]::Create()
$rng.GetBytes($key)

# 使用自定义密钥加密并保存
$plainText = "SuperSecretAPIKey2025!"
$securePwd = $plainText | ConvertTo-SecureString -AsPlainText -Force
$encrypted = $securePwd | ConvertFrom-SecureString -Key $key
$encrypted | Set-Content -Path "C:\Vault\api_key.enc.txt"

# 将密钥安全保存(实际场景中应使用密钥管理服务)
$key | Set-Content -Path "C:\Vault\aes_key.bin" -Encoding Byte

Write-Host "已使用 AES-256 密钥加密并保存"

执行结果示例:

1
已使用 AES-256 密钥加密并保存

在目标机器上,只需要加载相同的密钥文件即可还原密码:

1
2
3
4
5
6
7
8
9
10
# 在另一台机器上还原
$key = Get-Content -Path "C:\Vault\aes_key.bin" -Encoding Byte -ReadCount 0
$encrypted = Get-Content -Path "C:\Vault\api_key.enc.txt" -Raw
$securePwd = $encrypted | ConvertTo-SecureString -Key $key

# 还原为明文(仅用于需要明文的 API 调用场景)
$BSTR = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($securePwd)
$plainText = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto($BSTR)

Write-Host "还原后的 API 密钥: $($plainText.Substring(0,8))..."

执行结果示例:

1
还原后的 API 密钥: SuperSec...

构建 PSCredential 对象

在实际工作中,我们很少单独使用 SecureString,更多时候需要将它封装为 PSCredential 对象。PSCredential 是 PowerShell 中标准的凭据类型,几乎所有需要身份验证的 cmdlet(如 Invoke-CommandEnter-PSSessionNew-SmbMapping)都接受此类型作为 -Credential 参数。

1
2
3
4
5
6
7
8
9
10
11
# 从持久化的加密文件构建凭据对象
$username = "DOMAIN\ServiceAccount"
$encryptedPwd = Get-Content -Path "C:\Vault\encrypted_pwd.txt" -Raw
$securePwd = $encryptedPwd | ConvertTo-SecureString

$credential = New-Object System.Management.Automation.PSCredential($username, $securePwd)

# 展示凭据信息
Write-Host "用户名: $($credential.UserName)"
Write-Host "密码长度: $($credential.GetNetworkCredential().Password.Length) 个字符"
Write-Host "是否已加密: $($credential.Password.IsReadOnly())"

执行结果示例:

1
2
3
用户名: DOMAIN\ServiceAccount
密码长度: 17 个字符
是否已加密: True

构建好 PSCredential 对象后,就可以直接传递给需要身份验证的命令:

1
2
3
4
5
6
7
8
9
10
11
# 使用凭据远程执行命令
$session = New-PSSession -ComputerName "SRV01" -Credential $credential
$result = Invoke-Command -Session $session -ScriptBlock {
Get-Process | Sort-Object -Property WorkingSet64 -Descending | Select-Object -First 5
}
Remove-PSSession -Session $session

# 输出远程进程信息
foreach ($proc in $result) {
Write-Host "$($proc.Name) - $([math]::Round($proc.WorkingSet64 / 1MB, 2)) MB"
}

执行结果示例:

1
2
3
4
5
sqlservr - 2456.32 MB
w3wp - 856.71 MB
Microsoft.Exchange - 412.48 MB
MsMpEng - 198.15 MB
System - 45.89 MB

批量管理多个凭据

在管理多台服务器或多个服务时,往往需要维护多组凭据。可以将凭据信息存储在结构化的配置文件中,通过循环批量加载使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 定义服务凭据清单
$services = @(
@{ Name = "DatabaseServer"; User = "sa"; EncryptedFile = "C:\Vault\db_pwd.txt" }
@{ Name = "FileShare"; User = "fileadmin"; EncryptedFile = "C:\Vault\fs_pwd.txt" }
@{ Name = "BackupService"; User = "backup"; EncryptedFile = "C:\Vault\bk_pwd.txt" }
)

# 批量构建凭据对象
$credentials = @{}
foreach ($svc in $services) {
$encryptedPwd = Get-Content -Path $svc.EncryptedFile -Raw
if ($encryptedPwd) {
$securePwd = $encryptedPwd | ConvertTo-SecureString
$cred = New-Object System.Management.Automation.PSCredential($svc.User, $securePwd)
$credentials[$svc.Name] = $cred
Write-Host "[OK] 已加载凭据: $($svc.Name) ($($svc.User))"
}
else {
Write-Host "[WARN] 凭据文件为空: $($svc.EncryptedFile)"
}
}

Write-Host "`n共加载 $($credentials.Count) 组凭据"

执行结果示例:

1
2
3
4
5
[OK] 已加载凭据: DatabaseServer (sa)
[OK] 已加载凭据: FileShare (fileadmin)
[OK] 已加载凭据: BackupService (backup)

共加载 3 组凭据

通过哈希表存储凭据对象,在后续脚本中可以按服务名称快速查找并使用对应的凭据,既清晰又安全。

注意事项

  • DPAPI 绑定用户和机器:默认的 ConvertFrom-SecureString 使用 DPAPI 加密,密文与当前 Windows 用户账户绑定。如果切换用户或在其他机器上运行,将无法解密。跨机器场景请使用自定义密钥模式。
  • 避免在代码中硬编码明文ConvertTo-SecureString -AsPlainText -Force 虽然方便,但明文会在脚本文件和进程内存中暴露。生产环境应使用 Get-Credential 或从加密文件加载。
  • 自定义密钥的安全分发:使用 -Key 参数时,密钥文件本身需要妥善保管。建议通过环境变量、Azure Key Vault、HashiCorp Vault 等专业密钥管理服务分发,而非将密钥文件提交到代码仓库。
  • 及时释放 BSTR 指针:使用 [Marshal]::SecureStringToBSTR() 还原明文后,应调用 [Marshal]::ZeroFreeBSTR() 释放内存,避免明文在内存中残留时间过长。
  • 跨平台限制:在 PowerShell 7 的 Linux/macOS 上,SecureString 的加密机制不同于 Windows DPAPI,保护效果有限。如果在非 Windows 平台运行,建议使用平台原生的密钥管理工具。
  • 日志脱敏:确保脚本中的 Write-HostWrite-Verbose 等输出语句不会打印 SecureString 的内容。PSCredential 对象的 .Password 属性在字符串上下文中会显示为 System.Security.SecureString,不会泄露明文,但仍需避免调用 .GetNetworkCredential().Password 后输出结果。

PowerShell 技能连载 - 加密与哈希

适用于 PowerShell 5.1 及以上版本

在运维脚本中,加密和哈希操作无处不在——验证文件完整性、保护敏感配置、安全传输数据、存储密码哈希。.NET 的 System.Security.Cryptography 命名空间提供了丰富的加密算法,PowerShell 可以直接调用这些类实现各种加密操作。理解哈希(不可逆)和加密(可逆)的区别,以及对称加密和非对称加密的适用场景,是编写安全脚本的基础。

本文将讲解 PowerShell 中的加密与哈希操作,以及实用的安全工具。

哈希计算

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
# 计算字符串哈希
function Get-StringHash {
param(
[Parameter(Mandatory)][string]$Text,
[ValidateSet("MD5", "SHA1", "SHA256", "SHA384", "SHA512")]
[string]$Algorithm = "SHA256"
)

$bytes = [System.Text.Encoding]::UTF8.GetBytes($Text)
$hashAlg = [System.Security.Cryptography.HashAlgorithm]::Create($Algorithm)
$hashBytes = $hashAlg.ComputeHash($bytes)
$hashHex = [BitConverter]::ToString($hashBytes) -replace '-', ''

return $hashHex.ToLower()
}

$password = "MyP@ssw0rd"
Write-Host "原文:$password"
Write-Host "MD5:$(Get-StringHash $password MD5)"
Write-Host "SHA1:$(Get-StringHash $password SHA1)"
Write-Host "SHA256:$(Get-StringHash $password SHA256)"

# 计算文件哈希(验证完整性)
$fileHash = Get-FileHash "C:\Windows\notepad.exe" -Algorithm SHA256
Write-Host "`n文件哈希:" -ForegroundColor Cyan
Write-Host " 文件:$($fileHash.Path)"
Write-Host " SHA256:$($fileHash.Hash)"

# 批量计算目录文件哈希
function Get-DirectoryHash {
param([string]$Path = ".")

Get-ChildItem $Path -File | ForEach-Object {
$hash = (Get-FileHash $_.FullName -Algorithm SHA256).Hash
[PSCustomObject]@{
File = $_.Name
Size = $_.Length
SHA256 = $hash.Substring(0, 16) + "..."
}
} | Format-Table -AutoSize
}

Get-DirectoryHash -Path "C:\Scripts"

# HMAC 哈希(带密钥的哈希,用于消息认证)
function Get-HmacHash {
param([string]$Message, [string]$Secret)

$key = [System.Text.Encoding]::UTF8.GetBytes($Secret)
$hmac = [System.Security.Cryptography.HMACSHA256]::new($key)
$msgBytes = [System.Text.Encoding]::UTF8.GetBytes($Message)
$hashBytes = $hmac.ComputeHash($msgBytes)
return [BitConverter]::ToString($hashBytes) -replace '-', ''
}

$hmac = Get-HmacHash -Message "api call data" -Secret "my-secret-key"
Write-Host "HMAC-SHA256:$($hmac.Substring(0, 16))..."

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
原文:MyP@ssw0rd
MD5:a3bf2a57d727b47e9c8...
SHA1:482c811da5d5b4bc6d49...
SHA256:a765f9e2a8b7c6d5e4f3...

文件哈希:
文件:C:\Windows\notepad.exe
SHA256:A1B2C3D4E5F6...

File Size SHA256
---- ---- ------
deploy.ps1 10240 a1b2c3d4e5f6...
config.json 2048 d4e5f6a7b8c9...
HMAC-SHA256:e5f6a7b8c9d0...

对称加密(AES)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
# AES 对称加密/解密
function Protect-AesString {
param(
[Parameter(Mandatory)][string]$PlainText,
[Parameter(Mandatory)][string]$Key
)

# 从密钥生成 256 位 AES 密钥和 IV
$keyBytes = [System.Security.Cryptography.SHA256]::Create().ComputeHash(
[System.Text.Encoding]::UTF8.GetBytes($Key)
)
$aes = [System.Security.Cryptography.Aes]::Create()
$aes.Key = $keyBytes
$aes.GenerateIV()

$encryptor = $aes.CreateEncryptor()
$plainBytes = [System.Text.Encoding]::UTF8.GetBytes($PlainText)
$encryptedBytes = $encryptor.TransformFinalBlock($plainBytes, 0, $plainBytes.Length)

# IV + 密文合并,Base64 输出
$result = New-Object byte[] ($aes.IV.Length + $encryptedBytes.Length)
[System.Array]::Copy($aes.IV, $result, $aes.IV.Length)
[System.Array]::Copy($encryptedBytes, 0, $result, $aes.IV.Length, $encryptedBytes.Length)

return [Convert]::ToBase64String($result)
}

function Unprotect-AesString {
param(
[Parameter(Mandatory)][string]$EncryptedText,
[Parameter(Mandatory)][string]$Key
)

$keyBytes = [System.Security.Cryptography.SHA256]::Create().ComputeHash(
[System.Text.Encoding]::UTF8.GetBytes($Key)
)
$allBytes = [Convert]::FromBase64String($EncryptedText)

$aes = [System.Security.Cryptography.Aes]::Create()
$aes.Key = $keyBytes

# 提取 IV(前 16 字节)
$iv = New-Object byte[] $aes.BlockSize.DivRem(8)[0]
[System.Array]::Copy($allBytes, $iv, $iv.Length)
$aes.IV = $iv

# 提取密文
$cipherBytes = New-Object byte[] ($allBytes.Length - $iv.Length)
[System.Array]::Copy($allBytes, $iv.Length, $cipherBytes, 0, $cipherBytes.Length)

$decryptor = $aes.CreateDecryptor()
$decryptedBytes = $decryptor.TransformFinalBlock($cipherBytes, 0, $cipherBytes.Length)

return [System.Text.Encoding]::UTF8.GetString($decryptedBytes)
}

# 加密/解密示例
$secret = "数据库连接字符串:Server=prod-db;Password=P@ssw0rd"
$encryptionKey = "MyApp-Encryption-Key-2025"

$encrypted = Protect-AesString -PlainText $secret -Key $encryptionKey
Write-Host "加密后:$($encrypted.Substring(0, 30))..." -ForegroundColor Cyan

$decrypted = Unprotect-AesString -EncryptedText $encrypted -Key $encryptionKey
Write-Host "解密后:$decrypted" -ForegroundColor Green

执行结果示例:

1
2
加密后:a1b2c3d4e5f67890abcdef...
解密后:数据库连接字符串:Server=prod-db;Password=P@ssw0rd

文件加密

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
# 文件加密/解密
function Protect-File {
param(
[Parameter(Mandatory)][string]$Path,
[Parameter(Mandatory)][string]$Key
)

$keyBytes = [System.Security.Cryptography.SHA256]::Create().ComputeHash(
[System.Text.Encoding]::UTF8.GetBytes($Key)
)
$aes = [System.Security.Cryptography.Aes]::Create()
$aes.Key = $keyBytes
$aes.GenerateIV()

$plainBytes = [System.IO.File]::ReadAllBytes($Path)

$encryptor = $aes.CreateEncryptor()
$encryptedBytes = $encryptor.TransformFinalBlock($plainBytes, 0, $plainBytes.Length)

$output = New-Object byte[] ($aes.IV.Length + $encryptedBytes.Length)
[System.Array]::Copy($aes.IV, $output, $aes.IV.Length)
[System.Array]::Copy($encryptedBytes, 0, $output, $aes.IV.Length, $encryptedBytes.Length)

$encPath = $Path + ".enc"
[System.IO.File]::WriteAllBytes($encPath, $output)
Write-Host "已加密:$Path => $encPath" -ForegroundColor Green
}

function Unprotect-File {
param(
[Parameter(Mandatory)][string]$Path,
[Parameter(Mandatory)][string]$Key,
[string]$OutputPath
)

$keyBytes = [System.Security.Cryptography.SHA256]::Create().ComputeHash(
[System.Text.Encoding]::UTF8.GetBytes($Key)
)
$allBytes = [System.IO.File]::ReadAllBytes($Path)

$aes = [System.Security.Cryptography.Aes]::Create()
$aes.Key = $keyBytes

$iv = New-Object byte[] ($aes.BlockSize / 8)
[System.Array]::Copy($allBytes, $iv, $iv.Length)
$aes.IV = $iv

$cipherBytes = New-Object byte[] ($allBytes.Length - $iv.Length)
[System.Array]::Copy($allBytes, $iv.Length, $cipherBytes, 0, $cipherBytes.Length)

$decryptor = $aes.CreateDecryptor()
$decryptedBytes = $decryptor.TransformFinalBlock($cipherBytes, 0, $cipherBytes.Length)

$outPath = if ($OutputPath) { $OutputPath } else { $Path -replace '\.enc$', '' }
[System.IO.File]::WriteAllBytes($outPath, $decryptedBytes)
Write-Host "已解密:$Path => $outPath" -ForegroundColor Green
}

# 加密配置文件
Protect-File -Path "C:\MyApp\appsettings.json" -Key "Production-Key-2025"
Unprotect-File -Path "C:\MyApp\appsettings.json.enc" -Key "Production-Key-2025"

执行结果示例:

1
2
已加密:C:\MyApp\appsettings.json => C:\MyApp\appsettings.json.enc
已解密:C:\MyApp\appsettings.json.enc => C:\MyApp\appsettings.json

密码安全工具

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
# 安全密码哈希(用于存储密码)
function New-PasswordHash {
param(
[Parameter(Mandatory)][string]$Password,
[int]$SaltSize = 16,
[int]$Iterations = 100000
)

$salt = New-Object byte[] $SaltSize
[System.Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($salt)

$pbkdf2 = New-Object System.Security.Cryptography.Rfc2898DeriveBytes(
$Password, $salt, $Iterations, [System.Security.Cryptography.HashAlgorithmName]::SHA256
)
$hash = $pbkdf2.GetBytes(32)

$result = "PBKDF2-SHA256:$Iterations:$([Convert]::ToBase64String($salt)):$([Convert]::ToBase64String($hash))"
return $result
}

function Test-PasswordHash {
param(
[Parameter(Mandatory)][string]$Password,
[Parameter(Mandatory)][string]$StoredHash
)

$parts = $StoredHash -split ':'
$iterations = [int]$parts[1]
$salt = [Convert]::FromBase64String($parts[2])
$storedKey = [Convert]::FromBase64String($parts[3])

$pbkdf2 = New-Object System.Security.Cryptography.Rfc2898DeriveBytes(
$Password, $salt, $iterations, [System.Security.Cryptography.HashAlgorithmName]::SHA256
)
$testKey = $pbkdf2.GetBytes(32)

# 常量时间比较防止时序攻击
$diff = 0
for ($i = 0; $i -lt $storedKey.Length; $i++) {
$diff = $diff -bor ($storedKey[$i] -bxor $testKey[$i])
}
return ($diff -eq 0)
}

# 创建和验证密码哈希
$hash = New-PasswordHash -Password "MySecureP@ss"
Write-Host "密码哈希:$($hash.Substring(0, 40))..." -ForegroundColor Cyan

$valid = Test-PasswordHash -Password "MySecureP@ss" -StoredHash $hash
Write-Host "正确密码验证:$valid" -ForegroundColor Green

$invalid = Test-PasswordHash -Password "WrongPassword" -StoredHash $hash
Write-Host "错误密码验证:$invalid" -ForegroundColor Red

执行结果示例:

1
2
3
密码哈希:PBKDF2-SHA256:100000:a1b2c3d4e5f6g7h8...
正确密码验证:True
错误密码验证:False

注意事项

  1. 哈希 vs 加密:哈希是单向的(用于验证),加密是双向的(用于保护数据)。密码存储用哈希,配置保护用加密
  2. 不要用 MD5:MD5 和 SHA1 已被证明不安全,新项目应使用 SHA256 或更强的算法
  3. 密钥管理:加密的安全性取决于密钥的安全性。密钥不要硬编码在脚本中
  4. IV 唯一性:AES 加密每次应使用随机 IV,否则相同明文会产生相同密文
  5. 密码哈希:存储密码使用 PBKDF2、bcrypt 或 Argon2,不要用简单的 SHA256
  6. DPAPI:Windows 环境可以使用 DPAPI(Protect-CmsMessage)利用系统级密钥保护数据

PowerShell 技能连载 - 安全编码实践

适用于 PowerShell 5.1 及以上版本

安全编码不是可选项,而是生产环境的基本要求。PowerShell 脚本经常处理凭据、连接字符串、API 密钥等敏感信息,如果处理不当,这些信息可能泄露到日志文件、版本控制系统、甚至被恶意代码利用。从凭据管理、输入验证、代码签名,到审计日志和合规检查,PowerShell 提供了完整的安全工具链。

本文将讲解 PowerShell 安全编码的核心实践,帮助构建安全可靠的自动化脚本。

凭据安全管理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
# 不要这样做——明文密码
# $password = "MyP@ssw0rd123"

# 安全方式 1:Get-Credential 交互式输入
$credential = Get-Credential -Message "输入服务账户凭据"
Write-Host "用户名:$($credential.UserName)"
Write-Host "密码长度:$($credential.GetNetworkCredential().Password.Length)"

# 安全方式 2:加密文件存储(DPAPI,仅限当前用户和机器)
function Save-SecureCredential {
param(
[Parameter(Mandatory)]
[string]$Name,

[Parameter(Mandatory)]
[System.Management.Automation.PSCredential]$Credential
)

$credDir = "$env:APPDATA\PowerShell\Credentials"
New-Item $credDir -ItemType Directory -Force | Out-Null

$Credential | Export-Clixml -Path "$credDir\$Name.xml" -Force
Write-Host "凭据已安全保存:$Name" -ForegroundColor Green
}

function Get-SecureCredential {
param([Parameter(Mandatory)][string]$Name)

$path = "$env:APPDATA\PowerShell\Credentials\$Name.xml"
if (-not (Test-Path $path)) {
throw "凭据文件不存在:$Name"
}
return Import-Clixml -Path $path
}

# 保存和读取凭据
$cred = Get-Credential -Message "输入数据库连接凭据"
Save-SecureCredential -Name "DBAdmin" -Credential $cred

$dbCred = Get-SecureCredential -Name "DBAdmin"
Write-Host "已加载凭据:$($dbCred.UserName)"

# 安全方式 3:Windows Credential Manager
Install-Module -Name CredentialManager -Force -Scope CurrentUser
New-StoredCredential -Target "MyApp-Prod" -UserName "svc_myapp" -Password "P@ssw0rd" -Persist LocalMachine
$storedCred = Get-StoredCredential -Target "MyApp-Prod"
Write-Host "从凭据管理器加载:$($storedCred.UserName)"

执行结果示例:

1
2
3
凭据已安全保存:DBAdmin
已加载凭据:admin@example.com
从凭据管理器加载:svc_myapp

输入验证与净化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
# 路径遍历攻击防护
function Get-SafeFilePath {
param([Parameter(Mandatory)][string]$UserInput)

# 规范化路径
$basePath = "C:\Data\Uploads"
$fullPath = [System.IO.Path]::GetFullPath(
[System.IO.Path]::Combine($basePath, $UserInput)
)

# 检查是否在允许的基目录下
if (-not $fullPath.StartsWith($basePath, [StringComparison]::OrdinalIgnoreCase)) {
throw "非法路径:$UserInput(路径遍历攻击)"
}

return $fullPath
}

# 测试路径遍历
$tests = @("report.csv", "..\..\Windows\System32\config\SAM", "subdir\file.txt")
foreach ($test in $tests) {
try {
$safePath = Get-SafeFilePath -UserInput $test
Write-Host "合法路径:$safePath" -ForegroundColor Green
} catch {
Write-Host "拦截:$test - $($_.Exception.Message)" -ForegroundColor Red
}
}

# SQL 注入防护——始终使用参数化查询
function Get-UserData {
param(
[Parameter(Mandatory)]
[string]$ConnectionString,

[Parameter(Mandatory)]
[string]$UserName
)

# 验证输入格式
if ($UserName -notmatch '^[a-zA-Z0-9_-]{1,50}$') {
throw "非法用户名格式:$UserName"
}

$query = "SELECT Id, Name, Email FROM Users WHERE Name = @userName"

$connection = New-Object System.Data.SqlClient.SqlConnection($ConnectionString)
$command = $connection.CreateCommand()
$command.CommandText = $query

# 使用参数化查询防止 SQL 注入
$param = $command.Parameters.Add("@userName", [System.Data.SqlDbType]::VarChar, 50)
$param.Value = $UserName

try {
$connection.Open()
$adapter = New-Object System.Data.SqlClient.SqlDataAdapter($command)
$table = New-Object System.Data.DataTable
$adapter.Fill($table) | Out-Null
return $table
} finally {
$connection.Close()
}
}

# 命令注入防护
function Invoke-SafeCommand {
param([Parameter(Mandatory)][string]$FileName)

# 白名单验证
$allowedExtensions = @('.csv', '.json', '.xml', '.txt')
$ext = [System.IO.Path]::GetExtension($FileName).ToLower()

if ($ext -notin $allowedExtensions) {
throw "不支持的文件类型:$ext"
}

if ($FileName -match '[<>|&]') {
throw "文件名包含非法字符"
}

# 使用参数数组而非字符串拼接
$psi = [System.Diagnostics.ProcessStartInfo]::new()
$psi.FileName = "cmd.exe"
$psi.Arguments = "/c type `"$FileName`""
$psi.UseShellExecute = $false
$psi.RedirectStandardOutput = $true

$process = [System.Diagnostics.Process]::Start($psi)
$output = $process.StandardOutput.ReadToEnd()
$process.WaitForExit()

return $output
}

执行结果示例:

1
2
3
合法路径:C:\Data\Uploads\report.csv
拦截:..\..\Windows\System32\config\SAM - 非法路径:..\..\Windows\System32\config\SAM(路径遍历攻击)
合法路径:C:\Data\Uploads\subdir\file.txt

脚本代码签名

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 获取代码签名证书
$cert = Get-ChildItem Cert:\CurrentUser\My -CodeSigningCert | Select-Object -First 1

if (-not $cert) {
Write-Host "未找到代码签名证书,创建自签名证书用于测试..." -ForegroundColor Yellow
$cert = New-SelfSignedCertificate -Type CodeSigningCert `
-Subject "CN=PowerShell Script Signing" `
-CertStoreLocation "Cert:\CurrentUser\My"
Write-Host "已创建测试证书:$($cert.Thumbprint)" -ForegroundColor Green
}

# 对脚本进行签名
$scriptPath = "C:\Scripts\Deploy-App.ps1"
Set-AuthenticodeSignature -FilePath $scriptPath -Certificate $cert
Write-Host "脚本已签名:$scriptPath" -ForegroundColor Green

# 验证签名
$signature = Get-AuthenticodeSignature $scriptPath
Write-Host "签名状态:$($signature.Status)"
Write-Host "签名者:$($signature.SignerCertificate.Subject)"

# 设置执行策略
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
Write-Host "执行策略已设为 RemoteSigned" -ForegroundColor Green

执行结果示例:

1
2
3
4
5
已创建测试证书:A1B2C3D4E5F6...
脚本已签名:C:\Scripts\Deploy-App.ps1
签名状态:Valid
签名者:CN=PowerShell Script Signing
执行策略已设为 RemoteSigned

审计日志与合规

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
function Write-AuditLog {
<#
.SYNOPSIS
写入安全审计日志
#>
param(
[Parameter(Mandatory)]
[string]$Action,

[string]$Target = "",

[string]$Result = "Success",

[string]$Details = ""
)

$entry = [PSCustomObject]@{
Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff'
User = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
Computer = $env:COMPUTERNAME
Action = $Action
Target = $Target
Result = $Result
Details = $Details
}

$logDir = "C:\Logs\Audit"
New-Item $logDir -ItemType Directory -Force | Out-Null

$logFile = "$logDir\audit-$(Get-Date -Format 'yyyy-MM').csv"

if (Test-Path $logFile) {
$entry | Export-Csv $logFile -Append -NoTypeInformation -Encoding UTF8
} else {
$entry | Export-Csv $logFile -NoTypeInformation -Encoding UTF8
}
}

# 关键操作自动记录审计日志
function Restart-ServiceWithAudit {
param(
[Parameter(Mandatory)]
[string]$ServiceName,

[string]$Reason
)

Write-AuditLog -Action "ServiceRestart" -Target $ServiceName -Details "原因:$Reason"

try {
Restart-Service -Name $ServiceName -Force -ErrorAction Stop
$svc = Get-Service $ServiceName
Write-AuditLog -Action "ServiceRestartResult" -Target $ServiceName `
-Result "Success" -Details "服务状态:$($svc.Status)"
Write-Host "服务已重启:$ServiceName" -ForegroundColor Green
} catch {
Write-AuditLog -Action "ServiceRestartResult" -Target $ServiceName `
-Result "Failed" -Details $_.Exception.Message
Write-Host "重启失败:$($_.Exception.Message)" -ForegroundColor Red
throw
}
}

Restart-ServiceWithAudit -ServiceName "Spooler" -Reason "打印队列堵塞"

执行结果示例:

1
服务已重启:Spooler

安全基线检查脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
function Test-SecurityBaseline {
<#
.SYNOPSIS
执行基本的安全基线检查
#>

$results = @()

# 检查 1:执行策略
$policy = Get-ExecutionPolicy
$results += [PSCustomObject]@{
Check = "执行策略"
Value = $policy.ToString()
Status = if ($policy -in @('Restricted', 'RemoteSigned', 'AllSigned')) { "PASS" } else { "WARN" }
Details = "当前策略:$policy"
}

# 检查 2:管理员权限
$isAdmin = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
$results += [PSCustomObject]@{
Check = "管理员权限"
Value = $isAdmin.ToString()
Status = if (-not $isAdmin) { "PASS" } else { "WARN" }
Details = if ($isAdmin) { "以管理员身份运行,存在提权风险" } else { "以普通用户运行" }
}

# 检查 3:PowerShell 版本
$psVersion = $PSVersionTable.PSVersion.ToString()
$results += [PSCustomObject]@{
Check = "PowerShell 版本"
Value = $psVersion
Status = if ($PSVersionTable.PSVersion.Major -ge 5) { "PASS" } else { "FAIL" }
Details = "版本:$psVersion"
}

# 检查 4:脚本块日志
$sbLogging = Get-ItemProperty -Path "HKLM:\SOFTWARE\Policies\Microsoft\Windows\PowerShell\ScriptBlockLogging" `
-ErrorAction SilentlyContinue
$results += [PSCustomObject]@{
Check = "脚本块日志"
Value = if ($sbLogging.EnableScriptBlockLogging -eq 1) { "启用" } else { "未启用" }
Status = if ($sbLogging.EnableScriptBlockLogging -eq 1) { "PASS" } else { "WARN" }
Details = "建议启用脚本块日志记录"
}

$results | Format-Table -AutoSize

$failCount = ($results | Where-Object { $_.Status -eq "FAIL" }).Count
$warnCount = ($results | Where-Object { $_.Status -eq "WARN" }).Count

Write-Host "`n检查结果:$($results.Count) 项 | PASS: $($results.Count - $failCount - $warnCount) | WARN: $warnCount | FAIL: $failCount" -ForegroundColor $(if ($failCount -gt 0) { "Red" } elseif ($warnCount -gt 0) { "Yellow" } else { "Green" })
}

Test-SecurityBaseline

执行结果示例:

1
2
3
4
5
6
7
8
Check           Value    Status Details
----- ----- ------ -------
执行策略 RemoteSigned PASS 当前策略:RemoteSigned
管理员权限 False PASS 以普通用户运行
PowerShell 版本 7.4.2 PASS 版本:7.4.2
脚本块日志 启用 PASS 建议启用脚本块日志记录

检查结果:4 项 | PASS: 4 | WARN: 0 | FAIL: 0

注意事项

  1. 绝不硬编码密码:密码、密钥、Token 等敏感信息应使用凭据管理器、环境变量或密钥库
  2. 最小权限原则:脚本使用最小必要权限运行,避免使用管理员权限执行常规任务
  3. 输入验证:所有外部输入都必须验证和净化,防止注入攻击(SQL、命令、路径遍历)
  4. 传输加密:远程管理使用 HTTPS/SSL,禁用不安全的协议(TLS 1.0/1.1)
  5. 审计追踪:关键操作记录审计日志,包含操作者、时间、目标、结果
  6. 签名策略:生产环境使用 AllSignedRemoteSigned 执行策略,阻止未签名脚本运行

PowerShell 技能连载 - 加密与证书管理

适用于 PowerShell 5.1 及以上版本(Windows)

在信息安全日益重要的今天,加密和证书管理已成为运维人员的日常任务。无论是 HTTPS 网站的 TLS 证书续期、代码签名证书的管理、还是敏感数据的加密存储,PowerShell 都提供了完整的支持。Windows 证书存储(Certificate Store)通过 Cert: PSDrive 暴露给 PowerShell,使得证书的查询、导入、导出和过期监控都可以脚本化。

本文将讲解证书存储操作、TLS 证书管理、数据加密解密,以及证书过期监控的自动化方案。

证书存储操作

Windows 证书存储通过 Cert: 驱动器访问,结构类似文件系统:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# 查看证书存储结构
Get-ChildItem Cert:\ | Select-Object Name, Location

# 列出当前用户的个人证书
Get-ChildItem Cert:\CurrentUser\My |
Select-Object FriendlyName, Subject, Thumbprint,
@{N='颁发者'; E={$_.Issuer -replace 'CN=',''}},
@{N='过期日期'; E={$_.NotAfter.ToString('yyyy-MM-dd')}},
@{N='剩余天数'; E={([int]($_.NotAfter - (Get-Date)).TotalDays)}} |
Format-Table -AutoSize

# 列出本地计算机的证书
Get-ChildItem Cert:\LocalMachine\My |
Select-Object Subject, Thumbprint,
@{N='过期日期'; E={$_.NotAfter.ToString('yyyy-MM-dd')}} |
Format-Table -AutoSize

# 按主题搜索证书
$cert = Get-ChildItem Cert:\CurrentUser\My |
Where-Object { $_.Subject -match 'blog.vichamp.com' }
if ($cert) {
Write-Host "找到证书:$($cert.Thumbprint)" -ForegroundColor Green
Write-Host "主题:$($cert.Subject)"
Write-Host "过期:$($cert.NotAfter)"
}

执行结果示例:

1
2
3
4
5
6
7
8
Name       Location
---- --------
CurrentUser CurrentUser
LocalMachine LocalMachine

FriendlyName Subject Thumbprint 颁发者 过期日期 剩余天数
------------ ------- ---------- ------ -------- --------
blog.vichamp CN=blog.vichamp.com A1B2C3D4E5F6789012345678901234... Let's Encrypt 2025-08-15 85

导入和导出证书

证书的导入导出是运维中最常见的操作之一:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# 导入 PFX 证书(含私钥)到本地计算机
$pfxPath = "C:\Certs\blog.vichamp.com.pfx"
$password = Read-Host "输入 PFX 密码" -AsSecureString

Import-PfxCertificate -FilePath $pfxPath `
-CertStoreLocation Cert:\LocalMachine\My `
-Password $password

Write-Host "PFX 证书已导入到 LocalMachine\My" -ForegroundColor Green

# 导入 CER 证书(仅公钥)到受信任根证书
Import-Certificate -FilePath "C:\Certs\myCA.cer" `
-CertStoreLocation Cert:\LocalMachine\Root

# 导出证书为 CER 格式(公钥)
$cert = Get-ChildItem Cert:\LocalMachine\My |
Where-Object { $_.Subject -match 'blog.vichamp.com' }

Export-Certificate -Cert $cert -FilePath "C:\Certs\blog.vichamp.com.cer"
Write-Host "公钥已导出" -ForegroundColor Green

# 导出 PFX(含私钥,需要密码保护)
$exportPassword = Read-Host "设置导出密码" -AsSecureString
$cert | Export-PfxCertificate -FilePath "C:\Certs\backup.pfx" -Password $exportPassword
Write-Host "PFX 备份已创建" -ForegroundColor Green

执行结果示例:

1
2
3
PFX 证书已导入到 LocalMachine\My
公钥已导出
PFX 备份已创建

注意:私钥导出仅在证书标记为可导出时才允许。导入 PFX 时使用 -Exportable 参数可以允许后续导出。

证书过期监控

证书过期是生产事故的常见原因。以下是一个自动化的证书过期监控脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
function Get-CertificateExpiry {
<#
.SYNOPSIS
检查证书过期情况
#>
param(
[int]$WarningDays = 30,
[int]$CriticalDays = 7,
[string[]]$Stores = @('Cert:\LocalMachine\My', 'Cert:\CurrentUser\My')
)

$results = @()

foreach ($store in $Stores) {
$certs = Get-ChildItem $store -ErrorAction SilentlyContinue

foreach ($cert in $certs) {
$daysLeft = [math]::Floor(($cert.NotAfter - (Get-Date)).TotalDays)

$status = if ($daysLeft -le 0) { 'EXPIRED' }
elseif ($daysLeft -le $CriticalDays) { 'CRITICAL' }
elseif ($daysLeft -le $WarningDays) { 'WARNING' }
else { 'OK' }

$results += [PSCustomObject]@{
Store = $store -replace 'Cert:\\',''
Subject = $cert.Subject -replace '^CN=',''
Issuer = $cert.Issuer -replace '^CN=',''
Thumbprint = $cert.Thumbprint
NotAfter = $cert.NotAfter.ToString('yyyy-MM-dd')
DaysLeft = $daysLeft
Status = $status
}
}
}

$results | Sort-Object DaysLeft |
Format-Table Subject, Issuer, NotAfter, DaysLeft, Status -AutoSize

# 汇总统计
$expired = ($results | Where-Object Status -eq 'EXPIRED').Count
$critical = ($results | Where-Object Status -eq 'CRITICAL').Count
$warning = ($results | Where-Object Status -eq 'WARNING').Count
$ok = ($results | Where-Object Status -eq 'OK').Count

Write-Host "`n总计:$($results.Count) 个证书" -ForegroundColor Cyan
Write-Host " OK: $ok | WARNING: $warning | CRITICAL: $critical | EXPIRED: $expired" -ForegroundColor $(if ($expired -or $critical) { 'Red' } elseif ($warning) { 'Yellow' } else { 'Green' })

return $results
}

# 检查所有证书,30 天内过期预警
Get-CertificateExpiry -WarningDays 30 -CriticalDays 7

执行结果示例:

1
2
3
4
5
6
7
8
9
Subject            Issuer          NotAfter   DaysLeft Status
------- ------ -------- -------- ------
blog.vichamp.com Let's Encrypt 2025-06-15 24 WARNING
api.internal.com Contoso CA 2025-05-29 7 CRITICAL
legacy.app.com Self-Signed 2025-05-20 -2 EXPIRED
intranet.corp.com Contoso CA 2026-03-15 297 OK

总计:8 个证书
OK: 5 | WARNING: 1 | CRITICAL: 1 | EXPIRED: 1

远程检查 TLS 证书

除了本地证书存储,还需要检查远程 HTTPS 站点的证书状态:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
function Test-TlsCertificate {
<#
.SYNOPSIS
检查远程 HTTPS 站点的 TLS 证书
#>
param(
[Parameter(Mandatory)]
[string[]]$HostName,

[int]$Port = 443,

[int]$WarningDays = 30
)

$results = foreach ($host_name in $HostName) {
try {
$tcpClient = New-Object System.Net.Sockets.TcpClient($host_name, $Port)
$sslStream = New-Object System.Net.Security.SslStream($tcpClient.GetStream(), $false, { $true })

$sslStream.AuthenticateAsClient($host_name, $null, [System.Security.Authentication.SslProtocols]::Tls12, $false)

$cert = $sslStream.RemoteCertificate
$x509 = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2($cert)

$daysLeft = [math]::Floor(($x509.NotAfter - (Get-Date)).TotalDays)

[PSCustomObject]@{
HostName = $host_name
Subject = $x509.Subject -replace '^CN=',''
Issuer = $x509.Issuer -replace '^CN=',''
NotBefore = $x509.NotBefore.ToString('yyyy-MM-dd')
NotAfter = $x509.NotAfter.ToString('yyyy-MM-dd')
DaysLeft = $daysLeft
Status = if ($daysLeft -le 0) { 'EXPIRED' }
elseif ($daysLeft -le $WarningDays) { 'WARNING' }
else { 'OK' }
TLSVersion = $sslStream.SslProtocol
KeyAlgorithm = $x509.SignatureAlgorithm.FriendlyName
}

$sslStream.Close()
$tcpClient.Close()
} catch {
[PSCustomObject]@{
HostName = $host_name
Status = "ERROR: $($_.Exception.Message)"
}
}
}

$results | Format-Table -AutoSize
}

# 检查多个站点的证书
$sites = @('blog.vichamp.com', 'github.com', 'google.com', 'expired.badssl.com')
Test-TlsCertificate -HostName $sites -WarningDays 30

执行结果示例:

1
2
3
4
5
6
HostName             Subject              Issuer             NotAfter   DaysLeft Status
-------- ------- ------ -------- -------- ------
blog.vichamp.com blog.vichamp.com Let's Encrypt 2025-08-15 85 OK
github.com github.com DigiCert 2025-03-15 -68 EXPIRED
google.com *.google.com GTS CA 1C3 2025-07-22 61 OK
expired.badssl.com *.badssl.com COMODO RSA 2015-09-30 -3509 EXPIRED

数据加密与解密

PowerShell 支持使用 Windows DPAPI 和 AES 进行数据加密:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
# 方法一:使用 DPAPI 加密(仅当前用户可解密)
$secret = "MyDatabasePassword123!"
$encrypted = $secret | ConvertTo-SecureString -AsPlainText -Force
$encryptedString = ConvertFrom-SecureString $encrypted
Write-Host "加密后:$($encryptedString.Substring(0, 40))..."

# 解密
$decryptedSecure = ConvertTo-SecureString $encryptedString
$cred = New-Object System.Management.Automation.PSCredential('temp', $decryptedSecure)
Write-Host "解密后:$($cred.GetNetworkCredential().Password)"

# 方法二:使用 AES 加密(跨机器可用)
function Protect-String {
param([string]$PlainText, [string]$KeyPath)

# 生成 AES 密钥
$aes = [System.Security.Cryptography.Aes]::Create()
$aes.KeySize = 256

if (-not (Test-Path $KeyPath)) {
$aes.GenerateKey()
$aes.GenerateIV()
$keyData = @{
Key = [Convert]::ToBase64String($aes.Key)
IV = [Convert]::ToBase64String($aes.IV)
}
$keyData | ConvertTo-Json | Set-Content $KeyPath
} else {
$keyData = Get-Content $KeyPath | ConvertFrom-Json
$aes.Key = [Convert]::FromBase64String($keyData.Key)
$aes.IV = [Convert]::FromBase64String($keyData.IV)
}

$encryptor = $aes.CreateEncryptor()
$bytes = [System.Text.Encoding]::UTF8.GetBytes($PlainText)
$encrypted = $encryptor.TransformFinalBlock($bytes, 0, $bytes.Length)

return [Convert]::ToBase64String($encrypted)
}

function Unprotect-String {
param([string]$EncryptedText, [string]$KeyPath)

$keyData = Get-Content $KeyPath | ConvertFrom-Json
$aes = [System.Security.Cryptography.Aes]::Create()
$aes.Key = [Convert]::FromBase64String($keyData.Key)
$aes.IV = [Convert]::FromBase64String($keyData.IV)

$decryptor = $aes.CreateDecryptor()
$bytes = [Convert]::FromBase64String($EncryptedText)
$decrypted = $decryptor.TransformFinalBlock($bytes, 0, $bytes.Length)

return [System.Text.Encoding]::UTF8.GetString($decrypted)
}

# 使用 AES 加密
$keyPath = "C:\Config\aes-key.json"
$encrypted = Protect-String -PlainText "SuperSecretPassword" -KeyPath $keyPath
Write-Host "AES 加密:$encrypted"

$decrypted = Unprotect-String -EncryptedText $encrypted -KeyPath $keyPath
Write-Host "AES 解密:$decrypted"

执行结果示例:

1
2
3
4
加密后:01000000d08c9ddf0115d1118c7a00c0...
解密后:MyDatabasePassword123!
AES 加密:a2V5QmFzZTY0RW5jcnlwdGVkRGF0YQ==
AES 解密:SuperSecretPassword

注意事项

  1. DPAPI 限制:DPAPI 加密的数据只能在同一用户上下文中解密,不适合跨机器场景。跨机器请使用 AES 加密
  2. 私钥保护:导出含私钥的 PFX 时务必使用强密码,不要将密码和 PFX 文件存储在同一位置
  3. 证书自动续期:Let’s Encrypt 等免费 CA 支持自动续期,建议使用 win-acme 或 Posh-ACME 自动化续期
  4. TLS 协议版本:生产环境应禁用 TLS 1.0/1.1,仅启用 TLS 1.2+。使用 [SslProtocols]::Tls12 指定
  5. 密钥轮换:AES 密钥应定期轮换,旧密钥安全销毁
  6. 审计日志:证书操作(导入、导出、删除)应记录审计日志,特别是涉及私钥的操作