-
Notifications
You must be signed in to change notification settings - Fork 408
Expand file tree
/
Copy pathMtlsCertificateCache.cs
More file actions
272 lines (237 loc) · 11.4 KB
/
Copy pathMtlsCertificateCache.cs
File metadata and controls
272 lines (237 loc) · 11.4 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
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
using System;
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Identity.Client.Core;
using Microsoft.Identity.Client.PlatformsCommon.Shared;
namespace Microsoft.Identity.Client.ManagedIdentity.V2
{
/// <summary>
/// Orchestrates mTLS binding retrieval:
/// 1) local in-memory cache
/// 2) per-key async gate (dedup concurrent mint)
/// 3) persisted cache (best-effort)
/// 4) factory mint + back-fill
/// Persistence is best-effort and non-throwing.
/// </summary>
internal sealed class MtlsBindingCache : IMtlsCertificateCache
{
private readonly KeyedSemaphorePool _gates = new();
private readonly ICertificateCache _memory;
private readonly IPersistentCertificateCache _persisted;
private readonly ConcurrentDictionary<string, byte> _forceMint = new();
/// <summary>
/// Inject both caches to avoid global state and enable testing.
/// </summary>
public MtlsBindingCache(ICertificateCache memory, IPersistentCertificateCache persisted)
{
_memory = memory ?? throw new ArgumentNullException(nameof(memory));
_persisted = persisted ?? throw new ArgumentNullException(nameof(persisted));
}
/// <summary>
/// Get or create mTLS binding info
/// </summary>
/// <param name="cacheKey"></param>
/// <param name="factory"></param>
/// <param name="cancellationToken"></param>
/// <param name="logger"></param>
/// <returns></returns>
/// <exception cref="ArgumentException"></exception>
/// <exception cref="ArgumentNullException"></exception>
public async Task<MtlsBindingInfo> GetOrCreateAsync(
string cacheKey,
Func<Task<MtlsBindingInfo>> factory,
CancellationToken cancellationToken,
ILoggerAdapter logger)
{
if (string.IsNullOrWhiteSpace(cacheKey))
{
throw new ArgumentException("cacheKey must be non-empty.", nameof(cacheKey));
}
if (factory is null)
{
throw new ArgumentNullException(nameof(factory));
}
bool forceMint = _forceMint.ContainsKey(cacheKey);
// 1) In-memory cache first
if (!forceMint && _memory.TryGet(cacheKey, out var cachedEntry, logger))
{
logger.Verbose(() =>
$"[PersistentCert] mTLS binding cache HIT (memory) for '{cacheKey}'.");
return new MtlsBindingInfo(
cachedEntry.Certificate,
cachedEntry.Endpoint,
cachedEntry.ClientId);
}
// 2) Per-key gate (dedupe concurrent mint)
await _gates.EnterAsync(cacheKey, cancellationToken).ConfigureAwait(false);
try
{
forceMint = _forceMint.ContainsKey(cacheKey);
// Re-check after acquiring the gate
if (!forceMint && _memory.TryGet(cacheKey, out cachedEntry, logger))
{
logger.Verbose(() =>
$"[PersistentCert] mTLS binding cache HIT (memory-after-gate) for '{cacheKey}'.");
return new MtlsBindingInfo(
cachedEntry.Certificate,
cachedEntry.Endpoint,
cachedEntry.ClientId);
}
// 3) Persistent cache (best-effort)
if (!forceMint && _persisted.Read(cacheKey, out var persistedEntry, logger))
{
logger.Verbose(() =>
$"[PersistentCert] mTLS binding cache HIT (persistent) for '{cacheKey}'.");
if (persistedEntry.Certificate.HasPrivateKey)
{
var memoryEntry = new CertificateCacheValue(
persistedEntry.Certificate,
persistedEntry.Endpoint,
persistedEntry.ClientId);
_memory.Set(cacheKey, in memoryEntry, logger);
return new MtlsBindingInfo(
memoryEntry.Certificate,
memoryEntry.Endpoint,
memoryEntry.ClientId);
}
// Defensive: persisted entry is unusable; dispose and mint new
persistedEntry.Certificate.Dispose();
logger.Verbose(() =>
"[PersistentCert] Skipping persisted cert without private key; minting new.");
}
// 4) Mint + back-fill mem + best-effort persist + prune
var mintedBinding = await factory().ConfigureAwait(false);
logger.Verbose(() =>
$"[PersistentCert] mTLS binding cache MISS -> minted new binding for '{cacheKey}'.");
var createdEntry = new CertificateCacheValue(
mintedBinding.Certificate,
mintedBinding.Endpoint,
mintedBinding.ClientId);
_memory.Set(cacheKey, in createdEntry, logger);
// Persist newest binding for this alias (best-effort; failures are logged by the implementation).
_persisted.Write(cacheKey, mintedBinding.Certificate, mintedBinding.Endpoint, logger);
// Then prune older/expired entries for this alias to keep the store bounded.
// This is also best-effort and must not throw.
_persisted.Delete(cacheKey, logger);
if (forceMint)
{
_forceMint.TryRemove(cacheKey, out _);
}
// Pass through the factory result (already an MtlsBindingInfo)
return mintedBinding;
}
finally
{
_gates.Release(cacheKey);
}
}
/// <summary>
/// Removes a certificate from both in-memory and persistent cache when SCHANNEL rejects it.
/// </summary>
public void RemoveBadCert(string cacheKey, ILoggerAdapter logger)
{
if (cacheKey != null)
{
_forceMint[cacheKey] = 0;
}
try
{
_memory.Remove(cacheKey, logger);
logger?.Verbose(() => $"[PersistentCert] Removed bad cert from memory cache for '{cacheKey}'");
}
catch (Exception ex)
{
logger?.Verbose(() => $"[PersistentCert] Error removing from memory cache: {ex.Message}");
}
try
{
_persisted.DeleteAllForAlias(cacheKey, logger);
logger?.Verbose(() => $"[PersistentCert] Removed bad cert from persistent cache for '{cacheKey}'");
}
catch (Exception ex)
{
logger?.Verbose(() => $"[PersistentCert] Error removing from persistent cache: {ex.Message}");
}
}
/// <summary>
/// Returns <see langword="true"/> if the cert's embedded public key does not match the
/// public key currently in the associated CNG container, indicating the container was
/// regenerated (e.g. by KeyGuard on reboot) while the cert on disk still references the
/// old key material.
/// </summary>
internal static bool IsCertKeyOrphaned(X509Certificate2 cert, ILoggerAdapter logger)
{
if (cert is null)
return true;
try
{
using var rsaKey = cert.GetRSAPrivateKey();
if (rsaKey is null)
{
// GetRSAPrivateKey() returns null for non-RSA certs (e.g. ECDSA) AND for RSA
// certs where the private key is inaccessible. Distinguish the two cases:
// if the cert has an RSA public key, the private key should be present but isn't
// → the cert is unusable. If there is no RSA public key, it's a non-RSA cert
// that we can't check → accept on faith.
using var pubKey = cert.GetRSAPublicKey();
return pubKey is not null; // RSA cert + inaccessible private key = orphaned
}
if (rsaKey is not RSACng rsaCng)
{
// Non-CNG RSA key (e.g. software CSP) — cannot perform KG container check; accept.
return false;
}
return !PublicKeyMatchesCert(rsaCng, cert, logger);
}
catch (CryptographicException ex)
{
logger?.Verbose(() =>
$"[PersistentCert] Cannot load private key for orphan check: {ex.Message}. Treating cert as unusable.");
return true;
}
}
/// <summary>
/// Returns <see langword="true"/> if the public key exported from <paramref name="containerKey"/>
/// matches the public key embedded in <paramref name="cert"/>.
/// A mismatch means the container holds different key material than when the cert was issued.
/// </summary>
/// <remarks>
/// Check 3 from the original proposal — comparing the CNG container's
/// <c>NCRYPT_LAST_MODIFIED_PROPERTY</c> against the cert's <c>NotBefore</c> — is
/// intentionally omitted. Both Check 3 and this modulus comparison detect the same event:
/// KeyGuard regenerating the key in the container post-reboot. This check is definitive:
/// two independently generated RSA keys sharing a modulus is computationally infeasible,
/// so a mismatch conclusively means the container was regenerated. Check 3 is a heuristic
/// with a known false-negative window (a reboot occurring within one minute of cert
/// issuance), and adds no coverage that this check does not already provide.
/// </remarks>
internal static bool PublicKeyMatchesCert(RSACng containerKey, X509Certificate2 cert, ILoggerAdapter logger)
{
try
{
var containerParams = containerKey.ExportParameters(includePrivateParameters: false);
using var certPubKey = cert.GetRSAPublicKey();
if (certPubKey is null)
return false;
var certParams = certPubKey.ExportParameters(includePrivateParameters: false);
return containerParams.Modulus is not null
&& certParams.Modulus is not null
&& containerParams.Modulus.AsSpan().SequenceEqual(certParams.Modulus)
&& containerParams.Exponent is not null
&& certParams.Exponent is not null
&& containerParams.Exponent.AsSpan().SequenceEqual(certParams.Exponent);
}
catch (CryptographicException ex)
{
logger?.Verbose(() =>
$"[PersistentCert] Public key export failed during orphan check: {ex.Message}. Treating cert as orphaned.");
return false;
}
}
}
}