Skip to content
Merged
14 changes: 13 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,25 @@

### Added

- New Parameter: `Send-ScriptMessage -MailType` > Chooses how an email with more than one recipient is sent. It overrides the configuration file's `MailType` setting, and `Group` is used when neither one sets it.
- `Send-ScriptMessage -MailType` and the configuration file's `MailType` setting choose how an email with more than one recipient is sent. `-MailType` overrides the setting, and `Group` is used when neither one sets it.
- `Group` sends one email to all of the To, CC, and BCC recipients.
- `OneOnOne` sends each To, CC, and BCC recipient a separate email with only that recipient in To, so no recipient can see who else received it. A recipient listed more than once gets one email.
- New Parameter: `Get-ScriptMessageConfig -ReturnConfigFilePath` > Adds a `ConfigFilePath` property to the returned settings, holding the full path of the configuration file that was read.
- `MgApp_CertificatePath` can now be relative to the configuration file's folder, so a certificate file kept beside the configuration file can be given as just its name, such as `PrivateKeyCertificate.pfx`.
- Certificate file authentication, with `MgApp_AuthenticationType` set to `CertificateFile`, now works on Windows PowerShell 5.1. It no longer needs PowerShell 7.4 or later, where `Connect-ScriptMessage` and `Send-ScriptMessage` stopped with "Connecting to Microsoft Graph using a certificate file is only supported with PowerShell version 7.4 and later."

### Changed

- Importing ScriptMessage again with `Import-Module -Force`, or removing and importing it, now clears the configuration file path set by `Set-ScriptMessageConfigFilePath`. Call `Set-ScriptMessageConfigFilePath` after importing the module.

### Deprecated

- Finding a relative `MgApp_CertificatePath` in PowerShell's current location is deprecated. When the file isn't in the configuration file's folder but is in the current location, ScriptMessage still uses it and shows a warning; the next major version will look only in the configuration file's folder. Move the file there, or set `MgApp_CertificatePath` to the file's full path.

### Removed

- `Send-ScriptMessage` no longer accepts pipeline input, which never sent the intended message: a piped value was bound to several parameters at once, such as the recipients, the sender, and the attachments. Piped input is now reported as an error and nothing is sent.

### Fixed

- `Send-ScriptMessage` now honors an explicit `-ChatType OneOnOne`. The configuration file's `ChatType` setting was used in its place, so a call asking for one-on-one chats sent a single group chat when that setting was `Group`. An explicit `-ChatType Group` was unaffected.
Expand All @@ -23,10 +33,12 @@
- Minor: The help for `Get-ScriptMessageConfig` and `Set-ScriptMessageConfigFilePath` now describes the `-Path` parameter, which it left blank.
- Minor: The fourth `Send-ScriptMessage` help example, which sends attachments held in variables, now runs as written. It failed with "Missing closing ')' in subexpression" when copied, and it ended without calling `Send-ScriptMessage`.
- Minor: `Get-ScriptMessageConfig`, `Connect-ScriptMessage`, and `Send-ScriptMessage` now say why a configuration file could not be used.
- Minor: With `MgApp_AuthenticationType` set to `CertificateFile`, a certificate file that doesn't exist is now reported by its path instead of as a missing password.

### Security

- `MgApp_CertificatePath` ran any PowerShell code written into it, so anyone who could edit the configuration file could run commands as the account sending messages. This affected every configuration file on PowerShell 7.4 and later, whatever authentication it was set up for. Only environment variables written as `$env:NAME` or `${env:NAME}` are expanded now, and the rest of the path is used exactly as written.
- With `MgApp_AuthenticationType` set to `CertificateFile`, connecting on PowerShell 7 left a copy of the certificate's private key in the profile of the account running the script, under `%APPDATA%\Microsoft\Crypto\Keys`: one more copy for each PowerShell session that connected, whether or not it disconnected. The private key is now kept only in memory. Copies left by earlier versions stay where they are.

---

Expand Down
167 changes: 167 additions & 0 deletions Research_Notes/Certificate-File-Behavior.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
# Certificate file behavior

How PowerShell and .NET load a `.pfx` certificate file, where that matters for `CertificateFile` authentication in
`Connect-ScriptMessage_MicrosoftGraph` (`ScriptMessage/Services/MicrosoftGraph.ps1`). Through `1.1.1` that
authentication type threw before PowerShell 7.4. This file records what stood in the way of Windows PowerShell 5.1,
what loading a certificate leaves behind in either edition, and how the module loads the file now (section 6).

Measured on **2026-09-23** on Windows 11 (10.0.26200), with Windows PowerShell **5.1.26100.9444** (.NET Framework
4.8.9345.0), PowerShell **7.6.6**, and `Microsoft.Graph.Authentication` **2.25.0**, in standalone scripts. The
certificates were throwaway self-signed RSA 2048 certificates, built in memory with PowerShell 7's
`CertificateRequest` and exported as `.pfx` files with and without a password. No certificate store was written to.
Only sections 5 and 6 connected to a tenant, with application permissions, and nothing was sent. For part of
section 5 and all of section 6, one throwaway certificate's public key was registered on the app temporarily.

**Claims are labelled with their evidence.**

- **Measured** means observed on the date above.
- **From source** means read from a file in this repository, or from a release tag.
- **Unverified** means nobody has tested it.

> **The one-line summary:** Windows PowerShell 5.1's `Get-PfxCertificate` cannot take a password, but .NET's
> `X509Certificate2` loads the same file in both editions, and `Connect-MgGraph` signs in with it in both. Loading
> normally writes the private key to the user profile, and under PowerShell 7 that file stays behind when the
> process exits, as the module's sign-in did through `1.1.1`. `EphemeralKeySet` writes nothing and signs in the
> same.

## 1. Measured: `Get-PfxCertificate` differs by edition

| Edition | `Get-Command Get-PfxCertificate -Syntax` |
| --- | --- |
| Windows PowerShell 5.1 | `[-FilePath] <string[]>` or `-LiteralPath <string[]>`, nothing else |
| PowerShell 7.6.6 | the same, plus `[-Password <securestring>] [-NoPromptForPassword]` |

In Windows PowerShell 5.1, `Get-PfxCertificate -FilePath` loaded a `.pfx` with no password, with a private key that
could sign. For a `.pfx` with a password it printed `Enter password:` and waited. With `-NonInteractive`, as a
scheduled task runs, it failed instead: "Windows PowerShell is in NonInteractive mode. Read and Prompt functionality is
not available."

**From source:** every release from `1.0.0` to `1.1.1` called `Get-PfxCertificate -NoPromptForPassword`, then again
with `-Password` from `MgApp_EncryptedCertificatePassword` if that failed, and threw before PowerShell 7.4.

## 2. Measured: loading with `X509Certificate2` works in both editions

`[System.Security.Cryptography.X509Certificates.X509Certificate2]::new(<path>, <SecureString>, <X509KeyStorageFlags>)`,
with PowerShell's location set to the folder holding the files and the process's working directory elsewhere. Both
editions gave the same results unless the table says otherwise.

| Case | Result |
| --- | --- |
| password file, right password | loads; `HasPrivateKey` is `True`, and the key signs SHA-256 data with PKCS #1 padding |
| password file, no password argument | `CryptographicException`: "The specified network password is not correct." |
| password file, empty `SecureString` | the same |
| password file, wrong password | the same |
| no-password file, no password argument | loads, with a private key |
| no-password file, a password given | `CryptographicException`: "The specified network password is not correct." |
| a file that does not exist | `CryptographicException`: "The system cannot find the file specified." |
| a bare file name, relative to PowerShell's location | the same; the process's working directory is used |
| that name after `$PSCmdlet.GetUnresolvedProviderPathFromPSPath()` | loads |
| a PowerShell drive path, `MyCerts:\with-password.pfx` | 5.1: `NotSupportedException`, "The given path's format is not supported." 7: `CryptographicException`, "The filename, directory name, or volume label syntax is incorrect." |

So .NET needs a full file system path, the same as `System.IO.File` in section 8 of
[PowerShell-Language-Behavior.md](./PowerShell-Language-Behavior.md), and it reports a missing password and a wrong
one with the same message.

## 3. Measured: the private key is written to the user profile while the certificate is loaded

The folders watched were `%APPDATA%\Microsoft\Crypto\Keys` and `%APPDATA%\Microsoft\Crypto\RSA\<user SID>`, by file
name only. Every key loaded as `RSACng`, and every file written went to `Keys`.

| Loader | Edition | While loaded | After `Dispose()` | After the process exits without `Dispose()` |
| --- | --- | --- | --- | --- |
| `X509Certificate2`, `DefaultKeySet` | 7 | one key file | removed | **left behind** (1,735 bytes) |
| `Get-PfxCertificate -Password` | 7 | one key file | removed | **left behind** (1,735 bytes) |
| `X509Certificate2`, `EphemeralKeySet` | 7 | none | none | none written |
| `X509Certificate2`, `DefaultKeySet` | 5.1 | one key file | removed | removed |
| `Get-PfxCertificate`, no-password file | 5.1 | one key file | removed | not tested |
| `X509Certificate2`, `EphemeralKeySet` | 5.1 | none | none | none written |

With `EphemeralKeySet` the private key still signed in both editions.

Through the module itself, under PowerShell 7: a child process imported this repository's module, replaced
`Connect-MgGraph` inside the module with a stand-in that kept the certificate it was given, and ran
`Connect-ScriptMessage` with `CertificateFile` settings and an encrypted password. After the process exited, one key
file (1,735 bytes) was left in `Keys`. The key files these runs left behind were deleted afterwards.

**From source:** `Connect-ScriptMessage_MicrosoftGraph` never disposes the certificate; it passes it to
`Connect-MgGraph -Certificate`, which keeps it. So through `1.1.1`, a script that connected with `CertificateFile`
authentication under PowerShell 7 left a copy of the app's private key in the profile of the account that ran it.
Section 5 measured this with the real `Connect-MgGraph` after a successful sign-in, with and without
`Disconnect-ScriptMessage`.

## 4. Measured: `Connect-MgGraph` takes a loaded certificate in both editions

With `Microsoft.Graph.Authentication` 2.25.0 imported in each edition, and no connection made, `Connect-MgGraph` had
the same three certificate parameters in both: `-Certificate` (`X509Certificate2`), `-CertificateSubjectName`, and
`-CertificateThumbprint`. None takes a file path, so the module has to load the file itself.

## 5. Measured: a certificate loaded from a file signs in, in both editions

Each attempt ran in a new process with application permissions. The direct attempts ran
`Connect-MgGraph -TenantId -ClientId -NoWelcome`, then `Get-MgContext`, then `Disconnect-MgGraph`. The module
attempts ran this repository's `Connect-ScriptMessage -ReturnConnectionInfo` with `CertificateFile` settings, an
encrypted password, and the real `Connect-MgGraph`, before section 6's change, so they loaded the file as every
release through `1.1.1` did.

The throwaway certificate was tried twice: first while it was not registered on the app, then after its public key
was registered. The first run shows that a sign-in uses the certificate it is given, since Entra rejected it; the
second shows the sign-in itself. As a control, the app's own certificate from `Cert:\CurrentUser\My`, used by
thumbprint, signed in under both editions.

| Loaded with | Edition | Not registered | Registered | Key file left after the process exited |
| --- | --- | --- | --- | --- |
| the module, `Connect-ScriptMessage` (`Get-PfxCertificate`) | 7 | not run | signed in | **one** |
| the module, then `Disconnect-ScriptMessage` | 7 | not run | signed in | **one** |
| `Get-PfxCertificate -Password` | 7 | `AADSTS700027` | signed in | **one** |
| `X509Certificate2`, `DefaultKeySet` | 7 | `AADSTS700027` | signed in | **one** |
| `X509Certificate2`, `EphemeralKeySet` | 7 | `AADSTS700027` | signed in | none |
| `X509Certificate2`, `DefaultKeySet` | 5.1 | `AADSTS700027` | signed in | none |
| `X509Certificate2`, `EphemeralKeySet` | 5.1 | `AADSTS700027` | signed in | none |

"Signed in" means `Get-MgContext` reported `AuthType` `AppOnly` and the app's `Mail.Send` role. The key file column
was the same in both runs where both ran, and a key file was left behind whether or not the attempt disconnected.
Every `AADSTS700027` arrived as a `Microsoft.Identity.Client.MsalServiceException`: "The certificate with identifier
used to sign the client assertion is not registered on application. [Reason - The key was not found. ...]". No
attempt failed on the machine.

Separately, in both editions, a signature over test data made with the private key, loaded with either
`DefaultKeySet` or `EphemeralKeySet`, verified with a copy of the certificate holding only the public key.

## 6. From source: how the module loads the file now

`Connect-ScriptMessage_MicrosoftGraph` has no PowerShell version check for `CertificateFile` authentication. It:

- Expands environment variables in `MgApp_CertificatePath` with `[regex]::Replace`, because a `-replace` scriptblock
turns into text under Windows PowerShell 5.1 ([PowerShell-Language-Behavior.md](./PowerShell-Language-Behavior.md)
section 4).
- Decides whether the path is relative with `IsPathRooted()` and `IsPSAbsolute()`, which gave the same answers in
both editions ([PowerShell-Language-Behavior.md](./PowerShell-Language-Behavior.md) section 13).
- Resolves the result with `$PSCmdlet.GetUnresolvedProviderPathFromPSPath()`, since .NET needs a full file system
path (section 2).
- Loads it with `New-Object` for `X509Certificate2` and `EphemeralKeySet`: first with an empty password, which opens a
file that has none, then with `MgApp_EncryptedCertificatePassword`. When the first attempt fails, it reports a file
that does not exist by its path before looking for a password, since .NET gives the same message for a missing
password and a wrong one but names no file when the file is missing. `New-Object` rather than `::new()` lets the
tests mock the load.

`Tests/Connect-ScriptMessage.Tests.ps1` loads real `.pfx` files, with and without a password, in both editions, and
fails if loading writes a key file to either folder in section 3. A copy of the module that loaded with
`DefaultKeySet` failed those tests in both editions.

**Measured:** after the change, each edition signed in through the module with the registered throwaway certificate,
each attempt in a new process: once with the settings and the full path handed to `Connect-ScriptMessage` directly,
through a `-ServiceConfig` parameter that was removed before release, and once from a configuration file whose
`MgApp_CertificatePath` was the file name alone, with PowerShell's current location in another folder. All four
signed in with no warning, and none left a key file after the process exited.

## What this does not establish

- Only a sign-in was tried, with one certificate and the app's `Mail.Send` role. No message was sent, so a token's use
after the sign-in was not exercised.
- Only one kind of `.pfx` was tried: a key created by .NET, which landed in `Keys` in both editions. A `.pfx` whose
key names a legacy CryptoAPI provider, such as one exported from the Windows certificate store, may be written to
`RSA\<user SID>` instead.
- PowerShell 7.0 to 7.3 were not run, so why releases through `1.1.1` required 7.4 is not established, and certificate
file authentication was not tried on those versions.
- Linux and macOS were not run.
- Whether Windows ever removes a key file left behind this way was not examined.
6 changes: 3 additions & 3 deletions Research_Notes/File-Encoding-And-Line-Endings.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,9 +165,9 @@ code page for a file with no byte order mark.
covers UTF-8 with and without a BOM, UTF-16 LE with a BOM, and the legacy code page.
- **From source:** the only configuration settings that hold free text are `MgApp_CertificateName` and
`MgApp_CertificatePath`; the rest are GUIDs, a thumbprint, booleans, names from a fixed set, and encrypted
strings. `Connect-ScriptMessage_MicrosoftGraph` uses `MgApp_CertificatePath` only for `CertificateFile`
authentication, which throws before PowerShell 7.4, so on 5.1 only `MgApp_CertificateName` can be misread in a
way that matters. On PowerShell 7 both settings can be misread from a legacy code page file.
strings. With the `1.1.0` reader, a misread character mattered in `MgApp_CertificateName` in either edition, but
in `MgApp_CertificatePath` only on PowerShell 7, because `CertificateFile` authentication, the only use of that
setting, needed PowerShell 7.4 through `1.1.1`.

**What this does not establish:**

Expand Down
8 changes: 4 additions & 4 deletions Research_Notes/Microsoft-Graph-Behavior.md
Original file line number Diff line number Diff line change
Expand Up @@ -222,10 +222,10 @@ managed identity, and environment variable sign-ins, and to `CurrentUser` only f
process's connection whatever kind it was. `AuthenticationHelpers.LogoutAsync`, which `Disconnect-MgGraph` calls,
clears the in-memory token cache, clears the connection, and deletes `mg.authrecord.json`.

**From source:** `Send-ScriptMessage` runs `Connect-ScriptMessage` before every send, and `Disconnect-MgGraph`
after it only when the configuration's `MgDisconnectWhenDone` is true, so a configuration without the setting
stays connected. `Connect-ScriptMessage_MicrosoftGraph` passes no `-ContextScope`, so every application
authentication type gets `Process` scope.
**From source:** `Send-ScriptMessage` runs `Connect-ScriptMessage_MicrosoftGraph` before every send, and
`Disconnect-MgGraph` after it only when the configuration's `MgDisconnectWhenDone` is true, so a configuration
without the setting stays connected. `Connect-ScriptMessage_MicrosoftGraph` passes no `-ContextScope`, so every
application authentication type gets `Process` scope.

**Inference, for `MgDisconnectWhenDone` with application permissions:**

Expand Down
Loading
Loading