Skip to content

Commit f2865d4

Browse files
authored
Fix certificate handling and permissions issues across multiple commands (#5433)
* Fix certificate handling and permissions issues across multiple commands * Fix certificate handling and permissions issues in various commands * Fix key container name retrieval and improve file permission handling in certificate operations * Fix key container name retrieval and ensure proper disposal of RSA private keys
1 parent 389dac9 commit f2865d4

8 files changed

Lines changed: 121 additions & 27 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/).
2727
- Changed `Export-PnPFlow -AsZipPackage` and `Export-PnPPowerApp` to ask for confirmation before overwriting an existing file when `-OutPath` is omitted, as they already did when `-OutPath` is specified. Unattended scripts that rely on the previous silent overwrite need to specify `-Force`. [#5421](https://github.com/pnp/powershell/pull/5421)
2828

2929
### Fixed
30+
- Fixed `Disconnect-PnPOnline` never removing the private key container which loading a certificate leaves behind, so every certificate based connect permanently added a private key file to the profile of the user. Certificates are loaded with `X509KeyStorageFlags.UserKeySet`, which places the key container under `%APPDATA%\Microsoft\Crypto`, while only the machine wide `%ProgramData%\Microsoft\Crypto\RSA\MachineKeys` was looked at, so the removal silently found nothing to do. Both locations are now checked, `Connect-PnPOnline -CertificateBase64Encoded` takes part in the cleanup as `-CertificatePath` already did, and a removal which does not succeed is written to the debug log rather than being swallowed. [#5433](https://github.com/pnp/powershell/pull/5433)
31+
- Fixed `New-PnPAzureCertificate -OutPfx` and `Register-PnPEntraIDApp -OutPath` writing the PFX holding the private key using the default permissions of the process on Linux and macOS, which the usual umask leaves readable by every local account. The file is now created readable and writable by its owner only. The accompanying CER file holds no private key and keeps the default permissions. [#5433](https://github.com/pnp/powershell/pull/5433)
32+
- Fixed `Connect-PnPOnline -CertificatePath` keeping the certificate file open for as long as the PowerShell session lived, which prevented the file from being moved, replaced or deleted and leaked a file handle on every connect. The same read could also return fewer bytes than the file holds and have the resulting truncated certificate reported as being corrupt or password protected. [#5433](https://github.com/pnp/powershell/pull/5433)
33+
- Fixed `Get-PnPTenantDeletedSite` failing to fetch the additional details of a site whose url contains an ampersand, as the url was placed into the CAML query without being escaped. [#5433](https://github.com/pnp/powershell/pull/5433)
34+
- Fixed `Get-PnPListItemAttachment` combining the file name returned by the server with `-Path` without reducing it to its file name part first, which meant a rooted value would have silently replaced the path that was asked for. [#5433](https://github.com/pnp/powershell/pull/5433)
3035
- Fixed delegated and application permission metadata and documentation for `Add-PnPPlannerBucket`, `Add-PnPPlannerRoster`, `Add-PnPPlannerRosterMember`, `Add-PnPPlannerTask`, `Get-PnPPlannerBucket`, `Get-PnPPlannerPlan`, `Get-PnPPlannerRosterMember`, `Get-PnPPlannerRosterPlan`, `Get-PnPPlannerTask`, `New-PnPPlannerPlan`, `Remove-PnPPlannerBucket`, `Remove-PnPPlannerPlan`, `Remove-PnPPlannerRoster`, `Remove-PnPPlannerRosterMember`, `Remove-PnPPlannerTask`, `Set-PnPPlannerBucket`, `Set-PnPPlannerPlan`, and `Set-PnPPlannerTask`. [#5432](https://github.com/pnp/powershell/pull/5432)
3136
- Fixed delegated and application permission metadata and documentation for `Add-PnPTodoTaskFileAttachment`, `Get-PnPTodoList`, `Get-PnPTodoTask`, `Get-PnPTodoTaskChecklistItem`, `Get-PnPTodoTaskFileAttachment`, `Get-PnPTodoTaskLinkedResource`, `New-PnPTodoList`, `New-PnPTodoTask`, `New-PnPTodoTaskChecklistItem`, `New-PnPTodoTaskLinkedResource`, `Remove-PnPTodoList`, `Remove-PnPTodoTask`, `Remove-PnPTodoTaskChecklistItem`, `Remove-PnPTodoTaskFileAttachment`, `Remove-PnPTodoTaskLinkedResource`, `Update-PnPTodoList`, `Update-PnPTodoTask`, and `Update-PnPTodoTaskChecklistItem`. [#5432](https://github.com/pnp/powershell/pull/5432)
3237
- Changed Microsoft To Do cmdlets to target the user supplied through `-User` directly, avoiding an additional Microsoft Graph user lookup and correctly encoding guest user principal names. Invalid user identifiers now surface the Microsoft Graph error returned by the To Do endpoint instead of being silently ignored. [#5432](https://github.com/pnp/powershell/pull/5432)

src/Commands/Base/ConnectOnline.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -650,7 +650,8 @@ private PnPConnection ConnectAppOnlyWithCertificate()
650650
{
651651
ReuseAuthenticationManager();
652652
}
653-
return PnPConnection.CreateWithCert(new Uri(Url), ClientId, Tenant, TenantAdminUrl, AzureEnvironment, certificate);
653+
// The key container behind this certificate was created by loading the bytes above, so it is ours to remove again on disconnect
654+
return PnPConnection.CreateWithCert(new Uri(Url), ClientId, Tenant, TenantAdminUrl, AzureEnvironment, certificate, true);
654655
}
655656
else if (ParameterSpecified(nameof(Thumbprint)))
656657
{

src/Commands/Base/NewAzureCertificate.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ protected override void ProcessRecord()
7575
OutPfx = Path.Combine(SessionState.Path.CurrentFileSystemLocation.Path, OutPfx);
7676
}
7777
byte[] certData = certificate.Export(X509ContentType.Pfx, CertificatePassword);
78-
File.WriteAllBytes(OutPfx, certData);
78+
CertificateHelper.WritePrivateKeyFile(OutPfx, certData);
7979
}
8080

8181
if (!string.IsNullOrWhiteSpace(OutCert))

src/Commands/Base/PnPConnection.cs

Lines changed: 62 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1052,38 +1052,83 @@ internal static void CleanupCryptoMachineKey(X509Certificate2 certificate)
10521052

10531053
if (Utilities.OperatingSystem.IsWindows())
10541054
{
1055-
var privateKey = (certificate.GetRSAPrivateKey() as RSACng)?.Key;
1056-
// var privateKey = (certificate.PrivateKey as RSACng)?.Key;
1057-
if (privateKey == null)
1055+
// The private key is asked for once and then offered to both providers. Returning early when it is not a Cryptography Next Generation
1056+
// key would make the legacy cryptographic service provider branch below unreachable, which is what used to leave the key containers
1057+
// of such a certificate behind. It is a caller owned object, so it is disposed again right away rather than being left to the garbage
1058+
// collector, as the handle it holds on the key would otherwise still be open while the file behind it is deleted further down.
1059+
string uniqueKeyContainerName;
1060+
using (var rsaPrivateKey = certificate.GetRSAPrivateKey())
1061+
{
1062+
#pragma warning disable CA1416 // Validate platform compatibility, this whole block only runs on Windows
1063+
// Both of these are the name of the file the key lives in. For the legacy providers that is UniqueKeyContainerName, where
1064+
// KeyContainerName is only the logical name of the container and never matches anything on disk.
1065+
uniqueKeyContainerName = (rsaPrivateKey as RSACng)?.Key?.UniqueName
1066+
?? (rsaPrivateKey as RSACryptoServiceProvider)?.CspKeyContainerInfo?.UniqueKeyContainerName;
1067+
#pragma warning restore CA1416 // Validate platform compatibility
1068+
}
1069+
1070+
if (string.IsNullOrEmpty(uniqueKeyContainerName))
1071+
{
1072+
Log.Debug("PnPConnection", "Unable to remove the private key of the certificate because its key container name could not be determined.");
10581073
return;
1074+
}
10591075

1060-
string uniqueKeyContainerName = privateKey.UniqueName;
1061-
if (uniqueKeyContainerName == null)
1076+
certificate.Reset();
1077+
1078+
// Certificates are loaded using X509KeyStorageFlags.UserKeySet, which puts the key container in the profile of the current user
1079+
// rather than in the machine wide store. Only the machine wide store used to be looked at here, so nothing was ever removed. The
1080+
// machine wide path is still checked last for a certificate which was loaded with MachineKeySet instead.
1081+
var candidatePaths = new List<string>();
1082+
1083+
var appDataPath = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
1084+
if (!string.IsNullOrEmpty(appDataPath))
10621085
{
1063-
#pragma warning disable CA1416 // Validate platform compatibilit
1064-
RSACryptoServiceProvider rsaCSP = certificate.GetRSAPrivateKey() as RSACryptoServiceProvider;
1065-
uniqueKeyContainerName = rsaCSP.CspKeyContainerInfo.KeyContainerName;
1086+
// Where a key created through Cryptography Next Generation ends up, which is what a modern certificate uses
1087+
candidatePaths.Add(Path.Combine(appDataPath, "Microsoft", "Crypto", "Keys", uniqueKeyContainerName));
1088+
1089+
// Where a key created through the legacy cryptographic service providers ends up
1090+
#pragma warning disable CA1416 // Validate platform compatibility, this whole block only runs on Windows
1091+
var currentUserSid = System.Security.Principal.WindowsIdentity.GetCurrent()?.User?.Value;
10661092
#pragma warning restore CA1416 // Validate platform compatibility
1093+
if (!string.IsNullOrEmpty(currentUserSid))
1094+
{
1095+
candidatePaths.Add(Path.Combine(appDataPath, "Microsoft", "Crypto", "RSA", currentUserSid, uniqueKeyContainerName));
1096+
}
10671097
}
1068-
certificate.Reset();
10691098

1099+
// A certificate loaded with X509KeyStorageFlags.MachineKeySet, which -X509KeyStorageFlags accepts and the documentation of
1100+
// Connect-PnPOnline shows, ends up machine wide instead of in the profile of the user
10701101
var programDataPath = Environment.GetEnvironmentVariable("ProgramData");
10711102
if (string.IsNullOrEmpty(programDataPath))
10721103
{
10731104
programDataPath = @"C:\ProgramData";
10741105
}
1075-
try
1106+
1107+
// Where a machine wide key created through Cryptography Next Generation ends up
1108+
candidatePaths.Add(Path.Combine(programDataPath, "Microsoft", "Crypto", "Keys", uniqueKeyContainerName));
1109+
1110+
// Where a machine wide key created through the legacy cryptographic service providers ends up
1111+
candidatePaths.Add(Path.Combine(programDataPath, "Microsoft", "Crypto", "RSA", "MachineKeys", uniqueKeyContainerName));
1112+
1113+
foreach (var candidatePath in candidatePaths)
10761114
{
1077-
var temporaryCertificateFilePath = $@"{programDataPath}\Microsoft\Crypto\RSA\MachineKeys\{uniqueKeyContainerName}";
1078-
if (System.IO.File.Exists(temporaryCertificateFilePath))
1115+
try
1116+
{
1117+
if (System.IO.File.Exists(candidatePath))
1118+
{
1119+
System.IO.File.Delete(candidatePath);
1120+
Log.Debug("PnPConnection", $"Removed the private key container of the certificate at '{candidatePath}'.");
1121+
return;
1122+
}
1123+
}
1124+
catch (Exception e)
10791125
{
1080-
System.IO.File.Delete(temporaryCertificateFilePath);
1126+
// Best effort cleanup, but no longer silent so that a failure to remove the key can be diagnosed
1127+
Log.Debug("PnPConnection", $"Unable to remove the private key container of the certificate at '{candidatePath}': {e.Message}");
10811128
}
10821129
}
1083-
catch (Exception)
1084-
{
1085-
// best effort cleanup
1086-
}
1130+
1131+
Log.Debug("PnPConnection", $"The private key container '{uniqueKeyContainerName}' of the certificate was not found in any of the known locations, so nothing was removed.");
10871132
}
10881133
}
10891134

src/Commands/EntraID/RegisterEntraIDApp.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -516,7 +516,7 @@ private X509Certificate2 GetCertificate(PSObject record)
516516
string pfxPath = Path.Combine(OutPath, $"{ApplicationName}.pfx");
517517
string cerPath = Path.Combine(OutPath, $"{ApplicationName}.cer");
518518
byte[] certPfxData = cert.Export(X509ContentType.Pfx, CertificatePassword);
519-
File.WriteAllBytes(pfxPath, certPfxData);
519+
CertificateHelper.WritePrivateKeyFile(pfxPath, certPfxData);
520520
record.Properties.Add(new PSVariableProperty(new PSVariable("Pfx file", pfxPath)));
521521

522522
byte[] certCerData = cert.Export(X509ContentType.Cert);

src/Commands/Lists/GetListItemAttachment.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,9 @@ protected override void ExecuteCmdlet()
6666
// Enumerate over the attachments and download them
6767
foreach (var attachment in attachmentFilesCollection)
6868
{
69-
string fileOut = System.IO.Path.Combine(Path, attachment.FileName);
69+
// Only the file name part of what the server returned is combined with the path, as Path.Combine silently discards the path it is
70+
// given when the second argument turns out to be rooted
71+
string fileOut = System.IO.Path.Combine(Path, System.IO.Path.GetFileName(attachment.FileName));
7072

7173
if (System.IO.File.Exists(fileOut) && !Force)
7274
{

src/Commands/Model/SPODeletedSite.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,8 @@ internal SPODeletedSite(DeletedSiteProperties deletedSiteProperties, bool fetchA
169169
var list = clientContext.Web.Lists.GetByTitle("DO_NOT_DELETE_SPLIST_TENANTADMIN_ALL_SITES_AGGREGATED_SITECOLLECTIONS");
170170
CamlQuery query = new CamlQuery
171171
{
172-
ViewXml = $"<View><Query><Where><Eq><FieldRef Name='SiteUrl' /><Value Type='Text'>{Url}</Value></Eq></Where></Query><RowLimit>1</RowLimit></View>"
172+
// The url is escaped as it goes into the CAML as element content, where an ampersand or an angle bracket would otherwise make the query malformed
173+
ViewXml = $"<View><Query><Where><Eq><FieldRef Name='SiteUrl' /><Value Type='Text'>{System.Security.SecurityElement.Escape(Url)}</Value></Eq></Where></Query><RowLimit>1</RowLimit></View>"
173174
};
174175

175176
var listItems = list.GetItems(query);

src/Commands/Utilities/CertificateHelper.cs

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,46 @@ internal static X509Certificate2 GetCertificateFromStore(string thumbprint)
116116
return null;
117117
}
118118

119+
/// <summary>
120+
/// Writes a file which holds private key material, ensuring that only the current user is able to read it back.
121+
/// </summary>
122+
/// <param name="path">Path of the file to write to.</param>
123+
/// <param name="contents">Contents to write, i.e. the exported PKCS#12 bytes.</param>
124+
/// <remarks>
125+
/// On Linux and macOS a file is created using the default permissions of the process, which the usual umask leaves at 0644, meaning any
126+
/// local account could read the private key. Windows has no equivalent notion and throws on these APIs, so there the file keeps inheriting
127+
/// the permissions of the folder it is written to.
128+
/// </remarks>
129+
internal static void WritePrivateKeyFile(string path, byte[] contents)
130+
{
131+
// Deliberately the framework check rather than the PnP one, as that is what lets the platform compatibility analyzer see that the code
132+
// below is unreachable on Windows, where the two Unix mode APIs throw
133+
if (System.OperatingSystem.IsWindows())
134+
{
135+
File.WriteAllBytes(path, contents);
136+
return;
137+
}
138+
139+
var fileStreamOptions = new FileStreamOptions
140+
{
141+
Mode = FileMode.Create,
142+
Access = FileAccess.Write,
143+
144+
// Applies when the file gets created, so that the key is never readable by others, not even briefly
145+
UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite
146+
};
147+
148+
using var fileStream = File.Open(path, fileStreamOptions);
149+
150+
// The mode above is only applied to a file which did not exist yet. Writing over a file which is already there truncates it and leaves
151+
// whatever permissions it had, so the mode is set again here, while the file is still empty, to cover that case as well. This is done
152+
// against the handle rather than against the path, so that replacing the path in between cannot send the mode change to another file
153+
// while the key is written to this one.
154+
File.SetUnixFileMode(fileStream.SafeFileHandle, UnixFileMode.UserRead | UnixFileMode.UserWrite);
155+
156+
fileStream.Write(contents, 0, contents.Length);
157+
}
158+
119159
/// <summary>
120160
/// Opens the X509Certificate2 at the provided path using the provided certificate password
121161
/// </summary>
@@ -136,15 +176,15 @@ internal static X509Certificate2 GetCertificateFromPath(Cmdlet cmdlet, string ce
136176
{
137177
Log.Debug("CertificateHelper", $"Reading certificate from file '{certificatePath}'");
138178

139-
var certFile = System.IO.File.OpenRead(certificatePath);
140-
if (certFile.Length == 0)
179+
// Read through to a byte array in one go rather than holding the file open: the stream used to be left undisposed, which kept the
180+
// certificate file locked for as long as the PowerShell session lived, and the partial read it performed could hand a truncated
181+
// certificate to the constructor below and have it reported as a corrupt certificate
182+
var certificateBytes = System.IO.File.ReadAllBytes(certificatePath);
183+
if (certificateBytes.Length == 0)
141184
{
142185
throw new PSArgumentException($"The specified certificate path '{certificatePath}' points to an empty file");
143186
}
144187

145-
var certificateBytes = new byte[certFile.Length];
146-
certFile.Read(certificateBytes, 0, (int)certFile.Length);
147-
148188
Log.Debug("CertificateHelper", $"Opening certificate in file '{certificatePath}' {(certificatePassword == null ? "without" : "using")} a certificate password");
149189
try
150190
{

0 commit comments

Comments
 (0)