Repository navigation
Expand file tree
/
Copy pathSend-ClearSignedSmimeMessage.ps1
More file actions
306 lines (252 loc) · 12.5 KB
/
Copy pathSend-ClearSignedSmimeMessage.ps1
File metadata and controls
306 lines (252 loc) · 12.5 KB
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
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
<#
.SYNOPSIS
Builds and sends a clear-signed S/MIME message via Microsoft Graph.
.DESCRIPTION
Loads a DER/Base64 .cer file, finds the matching private key in the Windows
certificate store, then builds a multipart/mixed MIME message and clear-signs
it using .NET's System.Security.Cryptography.Pkcs.SignedCms — which supports
both legacy CAPI and modern CNG private keys on all .NET runtimes.
The signed EML is saved locally and then sent via the Microsoft Graph
sendMail API (POST /v1.0/users/{from}/sendMail) using the raw MIME approach
described at https://learn.microsoft.com/en-us/graph/outlook-send-mime-message.
Prerequisites:
- Mailozaurr module 1.0.7 : Install-Module Mailozaurr -RequiredVersion 1.0.7
- Microsoft.Graph module : Install-Module Microsoft.Graph
- An Entra ID app (or delegated sign-in) with Mail.Send permission granted.
.PARAMETER CerPath
Full path to the .cer file (public certificate, DER or Base64 encoded).
Used to locate the matching certificate with private key in CurrentUser\My.
.PARAMETER FromEmail
Sender email address. Must match the mailbox used when connecting to Graph.
.PARAMETER ToEmail
Recipient email address.
.PARAMETER Subject
Email subject line.
.PARAMETER Body
HTML body of the message.
.PARAMETER AttachmentPath
Optional path to a file to attach. MIME content-type is detected automatically
from the file extension.
.PARAMETER OutputDirectory
Directory where the .eml will be saved before sending. Defaults to .\output.
.PARAMETER GraphClientId
Entra ID application (client) ID used to authenticate to Microsoft Graph.
.PARAMETER GraphTenantId
Entra ID tenant ID.
.PARAMETER GraphClientSecret
Client secret for app-only (client credentials) authentication.
If omitted, interactive delegated authentication is used instead.
.PARAMETER SaveOnly
If specified, builds and saves the EML but does not send via Graph.
.EXAMPLE
# Interactive (delegated) auth — prompts for sign-in
.\Send-ClearSignedSmimeMessage.ps1 `
-CerPath "C:\certs\test-smime.cer" `
-FromEmail "sender@contoso.com" `
-ToEmail "recipient@contoso.com" `
-Subject "Hello from PowerShell" `
-Body "<p>This message is clear-signed.</p>" `
-GraphClientId "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" `
-GraphTenantId "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
.EXAMPLE
# App-only auth with client secret
.\Send-ClearSignedSmimeMessage.ps1 `
-CerPath "C:\certs\test-smime.cer" `
-FromEmail "sender@contoso.com" `
-ToEmail "recipient@contoso.com" `
-Subject "Signed message" `
-Body "<p>Hello!</p>" `
-AttachmentPath "C:\docs\report.pdf" `
-GraphClientId "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" `
-GraphTenantId "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" `
-GraphClientSecret "your-client-secret"
.EXAMPLE
# Build and save EML only, no Graph send
.\Send-ClearSignedSmimeMessage.ps1 `
-CerPath "C:\certs\test-smime.cer" `
-FromEmail "sender@contoso.com" `
-ToEmail "recipient@contoso.com" `
-Subject "Test" `
-Body "<p>Test</p>" `
-OutputDirectory "C:\output" `
-SaveOnly
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)][string] $CerPath,
[Parameter(Mandatory)][string] $FromEmail,
[Parameter(Mandatory)][string] $ToEmail,
[string] $Subject = "",
[string] $Body = "",
[string] $AttachmentPath = "",
[string] $OutputDirectory = ".\output",
[string] $GraphClientId = "",
[string] $GraphTenantId = "",
[string] $GraphClientSecret = "",
[switch] $SaveOnly
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# ---------------------------------------------------------------------------
# 1. Locate Mailozaurr and load MimeKit
# ---------------------------------------------------------------------------
$module = Get-Module -ListAvailable -Name Mailozaurr |
Sort-Object Version -Descending |
Select-Object -First 1
if (-not $module) {
throw "Mailozaurr module not found. Run: Install-Module Mailozaurr -RequiredVersion 1.0.7"
}
$libPath = Join-Path $module.ModuleBase 'Lib\Default'
$mimeKitDll = Join-Path $libPath 'MimeKit.dll'
if (-not (Test-Path $mimeKitDll)) {
throw "MimeKit.dll not found at: $mimeKitDll"
}
Add-Type -Path $mimeKitDll
Write-Verbose "MimeKit loaded from: $mimeKitDll"
# ---------------------------------------------------------------------------
# 2. Load the .cer and locate the matching cert + private key in the Windows store
# ---------------------------------------------------------------------------
if (-not (Test-Path $CerPath)) {
throw ".cer file not found: $CerPath"
}
$cerCert = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($CerPath)
Write-Verbose "Loaded .cer: Subject=$($cerCert.Subject) Thumbprint=$($cerCert.Thumbprint)"
$certStore = [System.Security.Cryptography.X509Certificates.X509Store]::new(
[System.Security.Cryptography.X509Certificates.StoreName]::My,
[System.Security.Cryptography.X509Certificates.StoreLocation]::CurrentUser
)
$certStore.Open([System.Security.Cryptography.X509Certificates.OpenFlags]::ReadOnly)
$signingCert = $certStore.Certificates |
Where-Object { $_.Thumbprint -eq $cerCert.Thumbprint -and $_.HasPrivateKey } |
Select-Object -First 1
$certStore.Close()
if (-not $signingCert) {
throw "No certificate with a private key matching thumbprint '$($cerCert.Thumbprint)' found in CurrentUser\My."
}
Write-Verbose "Found signing cert in store: $($signingCert.Subject)"
# ---------------------------------------------------------------------------
# 3. Build the inner MIME body using MimeKit (HTML + optional attachment)
# ---------------------------------------------------------------------------
$null = [System.IO.Directory]::CreateDirectory($OutputDirectory)
$htmlBody = [MimeKit.TextPart]::new("html")
$htmlBody.Text = $Body
if ($AttachmentPath) {
if (-not (Test-Path $AttachmentPath)) {
throw "Attachment file not found: $AttachmentPath"
}
$fileName = [System.IO.Path]::GetFileName($AttachmentPath)
$contentType = [MimeKit.ContentType]::Parse(
[MimeKit.MimeTypes]::GetMimeType($fileName))
$attachStream = [System.IO.File]::OpenRead($AttachmentPath)
$attachment = [MimeKit.MimePart]::new($contentType.MediaType, $contentType.MediaSubtype)
$attachment.Content = [MimeKit.MimeContent]::new($attachStream)
$attachment.ContentDisposition = [MimeKit.ContentDisposition]::new(
[MimeKit.ContentDisposition]::Attachment)
$attachment.ContentTransferEncoding = [MimeKit.ContentEncoding]::Base64
$attachment.FileName = $fileName
$innerBody = [MimeKit.Multipart]::new("mixed")
$innerBody.Add($htmlBody)
$innerBody.Add($attachment)
Write-Verbose "Attachment added: $fileName ($($contentType.MediaType)/$($contentType.MediaSubtype))"
} else {
$innerBody = $htmlBody
}
# Serialise the inner body to bytes — these are exactly the bytes that get signed.
$innerStream = [System.IO.MemoryStream]::new()
$innerBody.WriteTo($innerStream)
$innerBytes = $innerStream.ToArray()
$innerStream.Dispose()
# ---------------------------------------------------------------------------
# 4. Sign with System.Security.Cryptography.Pkcs.SignedCms (detached signature)
# Works with both CAPI and CNG private keys on .NET Core / .NET 5+
# ---------------------------------------------------------------------------
$contentInfo = [System.Security.Cryptography.Pkcs.ContentInfo]::new($innerBytes)
$signedCms = [System.Security.Cryptography.Pkcs.SignedCms]::new($contentInfo, $true)
$cmsSigner = [System.Security.Cryptography.Pkcs.CmsSigner]::new($signingCert)
$cmsSigner.DigestAlgorithm = [System.Security.Cryptography.Oid]::new("2.16.840.1.101.3.4.2.1") # SHA-256
$cmsSigner.IncludeOption = [System.Security.Cryptography.X509Certificates.X509IncludeOption]::EndCertOnly
$signedCms.ComputeSignature($cmsSigner)
$signatureBytes = $signedCms.Encode()
Write-Verbose "CMS detached signature computed, $($signatureBytes.Length) bytes"
# ---------------------------------------------------------------------------
# 5. Assemble multipart/signed
# RFC 5751: boundary, protocol="application/pkcs7-signature", micalg="sha-256"
# ---------------------------------------------------------------------------
$boundary = "----=_Part_$(([System.Guid]::NewGuid().ToString('N')))"
$sigStream = [System.IO.MemoryStream]::new($signatureBytes)
$sigPart = [MimeKit.MimePart]::new("application", "pkcs7-signature")
$sigPart.Content = [MimeKit.MimeContent]::new($sigStream)
$sigPart.ContentDisposition = [MimeKit.ContentDisposition]::new(
[MimeKit.ContentDisposition]::Attachment)
$sigPart.ContentTransferEncoding = [MimeKit.ContentEncoding]::Base64
$sigPart.FileName = "smime.p7s"
$multipartSigned = [MimeKit.Multipart]::new("signed")
$multipartSigned.ContentType.Parameters.Add("protocol", "application/pkcs7-signature")
$multipartSigned.ContentType.Parameters.Add("micalg", "sha-256")
$multipartSigned.ContentType.Boundary = $boundary
$multipartSigned.Add($innerBody)
$multipartSigned.Add($sigPart)
# ---------------------------------------------------------------------------
# 6. Build the MimeMessage and save to .eml
# ---------------------------------------------------------------------------
$message = [MimeKit.MimeMessage]::new()
$message.From.Add([MimeKit.MailboxAddress]::new($FromEmail, $FromEmail))
$message.To.Add([MimeKit.MailboxAddress]::new($ToEmail, $ToEmail))
$message.Subject = $Subject
$message.Body = $multipartSigned
$emlPath = Join-Path $OutputDirectory "clearsigned.eml"
$message.WriteTo($emlPath)
Write-Host "EML saved to: $emlPath"
if ($SaveOnly) {
Write-Host "SaveOnly specified — skipping Graph send."
return
}
# ---------------------------------------------------------------------------
# 7. Connect to Microsoft Graph and send via raw MIME
#
# Graph requires the EML bytes to be base64-encoded and POSTed to:
# POST /v1.0/users/{from}/sendMail
# with Content-Type: text/plain
# per https://learn.microsoft.com/en-us/graph/outlook-send-mime-message
# ---------------------------------------------------------------------------
if (-not $GraphClientId -or -not $GraphTenantId) {
throw "GraphClientId and GraphTenantId are required to send. Use -SaveOnly to skip sending."
}
# Check Microsoft.Graph module is available
if (-not (Get-Module -ListAvailable -Name Microsoft.Graph.Users.Actions)) {
throw "Microsoft.Graph module not found. Run: Install-Module Microsoft.Graph"
}
Write-Verbose "Connecting to Microsoft Graph (tenant: $GraphTenantId)"
if ($GraphClientSecret) {
# App-only: client credentials flow
$secureSecret = ConvertTo-SecureString $GraphClientSecret -AsPlainText -Force
$credential = [System.Management.Automation.PSCredential]::new($GraphClientId, $secureSecret)
Connect-MgGraph -TenantId $GraphTenantId -ClientSecretCredential $credential -NoWelcome
Write-Verbose "Connected to Graph using app-only (client credentials)"
} else {
# Delegated: interactive browser sign-in
Connect-MgGraph -TenantId $GraphTenantId -ClientId $GraphClientId `
-Scopes "Mail.Send" -NoWelcome
Write-Verbose "Connected to Graph using delegated (interactive) auth"
}
# Read the saved EML bytes and base64-encode them
$emlBytes = [System.IO.File]::ReadAllBytes($emlPath)
$emlBase64 = [System.Convert]::ToBase64String($emlBytes)
# POST to /v1.0/users/{from}/sendMail with Content-Type: text/plain
# The body must be the raw base64 string — Graph decodes it server-side
$uri = "https://graph.microsoft.com/v1.0/users/$FromEmail/sendMail"
Invoke-MgGraphRequest `
-Method POST `
-Uri $uri `
-Body $emlBase64 `
-ContentType "text/plain"
Write-Host "Message sent via Microsoft Graph."
Write-Host ""
Write-Host "Summary:"
Write-Host " From : $FromEmail"
Write-Host " To : $ToEmail"
Write-Host " Subject : $Subject"
Write-Host " Signing : SHA-256 / multipart/signed (clear-signed)"
Write-Host " Cert : $($signingCert.Subject)"
Write-Host " Thumbprint : $($signingCert.Thumbprint)"
Write-Host " EML saved : $emlPath"