| | | 1 | | using System.Security.Cryptography; |
| | | 2 | | using System.Text; |
| | | 3 | | using AsiBackbone.Core.Signing; |
| | | 4 | | |
| | | 5 | | namespace AsiBackbone.Signing.LocalDevelopment; |
| | | 6 | | |
| | | 7 | | /// <summary> |
| | | 8 | | /// Provides local-development RSA signing and verification for AsiBackbone signing abstractions. |
| | | 9 | | /// </summary> |
| | | 10 | | /// <remarks> |
| | | 11 | | /// This service generates an in-process RSA key for samples and tests. It is not a production managed-key provider. |
| | | 12 | | /// </remarks> |
| | | 13 | | public sealed class LocalDevelopmentSigningService : IGovernanceSigningService, IGovernanceSignatureVerificationService, |
| | | 14 | | { |
| | | 15 | | private const string SupportedHashAlgorithm = "SHA-256"; |
| | 1 | 16 | | private static readonly Encoding SigningEncoding = Encoding.UTF8; |
| | | 17 | | |
| | | 18 | | private readonly LocalDevelopmentSigningOptions options; |
| | | 19 | | private readonly TimeProvider timeProvider; |
| | | 20 | | private readonly RSA rsa; |
| | 27 | 21 | | private readonly Lock rsaSync = new(); |
| | | 22 | | private readonly int keySizeBits; |
| | | 23 | | private bool disposed; |
| | | 24 | | |
| | | 25 | | /// <summary> |
| | | 26 | | /// Initializes a new instance of the <see cref="LocalDevelopmentSigningService" /> class with default local-develop |
| | | 27 | | /// </summary> |
| | | 28 | | public LocalDevelopmentSigningService() |
| | 6 | 29 | | : this(LocalDevelopmentSigningOptions.Create()) |
| | | 30 | | { |
| | 6 | 31 | | } |
| | | 32 | | |
| | | 33 | | /// <summary> |
| | | 34 | | /// Initializes a new instance of the <see cref="LocalDevelopmentSigningService" /> class. |
| | | 35 | | /// </summary> |
| | | 36 | | /// <exception cref="InvalidOperationException"> |
| | | 37 | | /// Thrown when the configured RSA key size is below the supported minimum or is not supported by the platform RSA |
| | | 38 | | /// provider. |
| | | 39 | | /// </exception> |
| | | 40 | | public LocalDevelopmentSigningService(LocalDevelopmentSigningOptions options) |
| | 22 | 41 | | : this(options, TimeProvider.System) |
| | | 42 | | { |
| | 15 | 43 | | } |
| | | 44 | | |
| | | 45 | | /// <summary> |
| | | 46 | | /// Initializes a new instance of the <see cref="LocalDevelopmentSigningService" /> class with an explicit clock. |
| | | 47 | | /// </summary> |
| | | 48 | | /// <param name="options">The local-development signing options. A snapshot is taken, so later changes to this insta |
| | | 49 | | /// <param name="timeProvider">The clock used to record the signing time in signing metadata.</param> |
| | | 50 | | /// <exception cref="InvalidOperationException"> |
| | | 51 | | /// Thrown when the configured RSA key size is below the supported minimum or is not supported by the platform RSA |
| | | 52 | | /// provider. |
| | | 53 | | /// </exception> |
| | 27 | 54 | | public LocalDevelopmentSigningService(LocalDevelopmentSigningOptions options, TimeProvider timeProvider) |
| | | 55 | | { |
| | 27 | 56 | | ArgumentNullException.ThrowIfNull(options); |
| | 27 | 57 | | ArgumentNullException.ThrowIfNull(timeProvider); |
| | | 58 | | |
| | | 59 | | // Validate the snapshot rather than the caller's instance, so the values that were checked are the values used. |
| | 27 | 60 | | LocalDevelopmentSigningOptions snapshot = options.Snapshot(); |
| | 27 | 61 | | snapshot.Validate(); |
| | | 62 | | |
| | 22 | 63 | | this.options = snapshot; |
| | 22 | 64 | | this.timeProvider = timeProvider; |
| | 22 | 65 | | rsa = CreateRsa(snapshot.KeySizeBits); |
| | 20 | 66 | | keySizeBits = rsa.KeySize; |
| | 20 | 67 | | } |
| | | 68 | | |
| | | 69 | | /// <inheritdoc /> |
| | | 70 | | public ValueTask<SigningResult> SignAsync( |
| | | 71 | | SigningRequest request, |
| | | 72 | | CancellationToken cancellationToken = default) |
| | | 73 | | { |
| | 33 | 74 | | ArgumentNullException.ThrowIfNull(request); |
| | 33 | 75 | | cancellationToken.ThrowIfCancellationRequested(); |
| | | 76 | | |
| | 33 | 77 | | string? validationFailure = ValidateSigningRequest(request); |
| | 33 | 78 | | if (validationFailure is not null) |
| | | 79 | | { |
| | | 80 | | // A host that set ReturnUnsignedOnFailure to false asked to be told when signing did not happen. Returning |
| | | 81 | | // unsigned metadata regardless meant a request-validation failure still produced an artifact whose IsSigned |
| | | 82 | | // was false and that no exception announced. |
| | 19 | 83 | | return options.ReturnUnsignedOnFailure |
| | 19 | 84 | | ? ValueTask.FromResult(CreateUnsignedFailureResult(request, validationFailure, validationFailure)) |
| | 19 | 85 | | : throw new InvalidOperationException( |
| | 19 | 86 | | $"Local-development signing rejected the signing request: {validationFailure}."); |
| | | 87 | | } |
| | | 88 | | |
| | | 89 | | try |
| | 14 | 90 | | { |
| | | 91 | | lock (rsaSync) |
| | | 92 | | { |
| | 14 | 93 | | ThrowIfDisposed(); |
| | | 94 | | |
| | | 95 | | // Sign the exact input Core supplied. For requests built by GovernanceArtifactSigner this is the versio |
| | | 96 | | // input that binds the signing policy context; for requests built without one it is the hash text. |
| | 14 | 97 | | byte[] signature = rsa.SignData(request.SignatureInput.Span, HashAlgorithmName.SHA256, RSASignaturePaddi |
| | | 98 | | |
| | 14 | 99 | | Dictionary<string, string> metadata = CreateBaseMetadata(request); |
| | 14 | 100 | | metadata["signing_status"] = "signed"; |
| | | 101 | | |
| | 14 | 102 | | var signingMetadata = SigningMetadata.Create( |
| | 14 | 103 | | signingHash: request.SigningHash, |
| | 14 | 104 | | hashAlgorithm: NormalizeHashAlgorithm(request.HashAlgorithm), |
| | 14 | 105 | | signature: Convert.ToBase64String(signature), |
| | 14 | 106 | | signatureAlgorithm: NormalizeRequired(options.SignatureAlgorithm, LocalDevelopmentSigningOptions.Def |
| | 14 | 107 | | keyId: NormalizeRequired(options.KeyId, LocalDevelopmentSigningOptions.DefaultKeyId), |
| | 14 | 108 | | keyVersion: NormalizeRequired(options.KeyVersion, LocalDevelopmentSigningOptions.DefaultKeyVersion), |
| | 14 | 109 | | provider: NormalizeRequired(options.ProviderName, LocalDevelopmentSigningOptions.DefaultProviderName |
| | 14 | 110 | | signedUtc: timeProvider.GetUtcNow(), |
| | 14 | 111 | | metadata: metadata); |
| | | 112 | | |
| | 14 | 113 | | return ValueTask.FromResult(SigningResult.FromMetadata(signingMetadata)); |
| | | 114 | | } |
| | | 115 | | } |
| | 0 | 116 | | catch (Exception exception) when (exception is CryptographicException or ObjectDisposedException or InvalidOpera |
| | | 117 | | { |
| | 0 | 118 | | if (!options.ReturnUnsignedOnFailure) |
| | | 119 | | { |
| | 0 | 120 | | throw; |
| | | 121 | | } |
| | | 122 | | |
| | 0 | 123 | | return ValueTask.FromResult(CreateUnsignedFailureResult(request, "localdev.signing.failed", exception.GetTyp |
| | | 124 | | } |
| | 14 | 125 | | } |
| | | 126 | | |
| | | 127 | | /// <inheritdoc /> |
| | | 128 | | public ValueTask<SignatureVerificationResult> VerifyAsync( |
| | | 129 | | SignatureVerificationRequest request, |
| | | 130 | | CancellationToken cancellationToken = default) |
| | | 131 | | { |
| | 8 | 132 | | ArgumentNullException.ThrowIfNull(request); |
| | 8 | 133 | | cancellationToken.ThrowIfCancellationRequested(); |
| | | 134 | | |
| | 8 | 135 | | SigningMetadata metadata = request.SigningMetadata; |
| | | 136 | | |
| | 8 | 137 | | if (!metadata.HasSignature) |
| | | 138 | | { |
| | 0 | 139 | | return ValueTask.FromResult(SignatureVerificationResult.MissingSignature("The local-development verifier rec |
| | | 140 | | } |
| | | 141 | | |
| | 8 | 142 | | if (!FixedTimeHashEquals(request.SigningHash, metadata.SigningHash)) |
| | | 143 | | { |
| | 1 | 144 | | return ValueTask.FromResult(SignatureVerificationResult.Failed( |
| | 1 | 145 | | "localdev.signature.hash-mismatch", |
| | 1 | 146 | | SignatureVerificationCategory.HashMismatch, |
| | 1 | 147 | | "The verification hash does not match the hash recorded in signing metadata.")); |
| | | 148 | | } |
| | | 149 | | |
| | 7 | 150 | | if (!IsSupportedHashAlgorithm(metadata.HashAlgorithm)) |
| | | 151 | | { |
| | 0 | 152 | | return ValueTask.FromResult(SignatureVerificationResult.Failed( |
| | 0 | 153 | | "localdev.signature.hash-algorithm-unsupported", |
| | 0 | 154 | | SignatureVerificationCategory.UnsupportedAlgorithm, |
| | 0 | 155 | | "The local-development verifier supports SHA-256 signing metadata only.")); |
| | | 156 | | } |
| | | 157 | | |
| | 7 | 158 | | if (!string.Equals(metadata.SignatureAlgorithm, NormalizeRequired(options.SignatureAlgorithm, LocalDevelopmentSi |
| | | 159 | | { |
| | 0 | 160 | | return ValueTask.FromResult(SignatureVerificationResult.Failed( |
| | 0 | 161 | | "localdev.signature.algorithm-mismatch", |
| | 0 | 162 | | SignatureVerificationCategory.UnsupportedAlgorithm, |
| | 0 | 163 | | "The signature algorithm does not match the configured local-development provider.")); |
| | | 164 | | } |
| | | 165 | | |
| | 7 | 166 | | if (!string.Equals(metadata.KeyId, NormalizeRequired(options.KeyId, LocalDevelopmentSigningOptions.DefaultKeyId) |
| | 7 | 167 | | || !string.Equals(metadata.KeyVersion, NormalizeRequired(options.KeyVersion, LocalDevelopmentSigningOptions. |
| | | 168 | | { |
| | 0 | 169 | | return ValueTask.FromResult(SignatureVerificationResult.Failed( |
| | 0 | 170 | | "localdev.signature.key-mismatch", |
| | 0 | 171 | | SignatureVerificationCategory.UnknownKeyVersion, |
| | 0 | 172 | | "The signature key reference does not match the configured local-development provider.")); |
| | | 173 | | } |
| | | 174 | | |
| | | 175 | | // The provider label is not part of the signature input, so it is authenticated only by this verifier refusing |
| | | 176 | | // label it does not own. Accepting any label let an artifact signed here claim another provider's identity. |
| | 7 | 177 | | if (!string.Equals(metadata.Provider, NormalizeRequired(options.ProviderName, LocalDevelopmentSigningOptions.Def |
| | | 178 | | { |
| | 1 | 179 | | return ValueTask.FromResult(SignatureVerificationResult.Failed( |
| | 1 | 180 | | "localdev.signature.provider-not-trusted", |
| | 1 | 181 | | SignatureVerificationCategory.UntrustedSigningContext, |
| | 1 | 182 | | "The signing provider does not match the configured local-development provider.")); |
| | | 183 | | } |
| | | 184 | | |
| | | 185 | | try |
| | 6 | 186 | | { |
| | | 187 | | lock (rsaSync) |
| | | 188 | | { |
| | 6 | 189 | | ThrowIfDisposed(); |
| | | 190 | | |
| | 6 | 191 | | byte[] signature = Convert.FromBase64String(metadata.Signature!); |
| | 6 | 192 | | bool verified = rsa.VerifyData(request.SignatureInput.Span, signature, HashAlgorithmName.SHA256, RSASign |
| | | 193 | | |
| | 6 | 194 | | return ValueTask.FromResult(verified |
| | 6 | 195 | | ? SignatureVerificationResult.Verified() |
| | 6 | 196 | | : SignatureVerificationResult.Failed( |
| | 6 | 197 | | "localdev.signature.invalid", |
| | 6 | 198 | | SignatureVerificationCategory.InvalidSignature, |
| | 6 | 199 | | "The local-development signature did not verify.")); |
| | | 200 | | } |
| | | 201 | | } |
| | 0 | 202 | | catch (FormatException) |
| | | 203 | | { |
| | 0 | 204 | | return ValueTask.FromResult(SignatureVerificationResult.Failed( |
| | 0 | 205 | | "localdev.signature.malformed", |
| | 0 | 206 | | SignatureVerificationCategory.InvalidSignature, |
| | 0 | 207 | | "The signature value is not valid Base64.")); |
| | | 208 | | } |
| | 0 | 209 | | catch (Exception exception) when (exception is CryptographicException or ObjectDisposedException or InvalidOpera |
| | | 210 | | { |
| | 0 | 211 | | return ValueTask.FromResult(SignatureVerificationResult.Failed( |
| | 0 | 212 | | "localdev.verification.failed", |
| | 0 | 213 | | SignatureVerificationCategory.Failed, |
| | 0 | 214 | | exception.GetType().Name)); |
| | | 215 | | } |
| | 6 | 216 | | } |
| | | 217 | | |
| | | 218 | | /// <inheritdoc /> |
| | | 219 | | public void Dispose() |
| | 44 | 220 | | { |
| | | 221 | | lock (rsaSync) |
| | | 222 | | { |
| | 44 | 223 | | if (disposed) |
| | | 224 | | { |
| | 24 | 225 | | return; |
| | | 226 | | } |
| | | 227 | | |
| | 20 | 228 | | disposed = true; |
| | 20 | 229 | | rsa.Dispose(); |
| | 20 | 230 | | } |
| | 44 | 231 | | } |
| | | 232 | | |
| | | 233 | | /// <summary> |
| | | 234 | | /// Creates an RSA key of exactly the requested size. |
| | | 235 | | /// </summary> |
| | | 236 | | /// <remarks> |
| | | 237 | | /// The key size is supplied at creation through <see cref="RSA.Create(int)" /> rather than assigned to |
| | | 238 | | /// <see cref="AsymmetricAlgorithm.KeySize" /> afterwards, whose behavior varies across platform providers. A size t |
| | | 239 | | /// provider rejects is reported as <see cref="InvalidOperationException" /> alongside the other configuration failu |
| | | 240 | | /// and a key whose generated size differs from the request is refused rather than used under a different size. |
| | | 241 | | /// </remarks> |
| | | 242 | | /// <param name="requestedKeySizeBits">The configured RSA key size in bits.</param> |
| | | 243 | | /// <returns>An RSA instance whose key size equals <paramref name="requestedKeySizeBits" />.</returns> |
| | | 244 | | /// <exception cref="InvalidOperationException">Thrown when the platform RSA provider cannot generate the requested |
| | | 245 | | private static RSA CreateRsa(int requestedKeySizeBits) |
| | | 246 | | { |
| | | 247 | | RSA created; |
| | | 248 | | |
| | | 249 | | try |
| | | 250 | | { |
| | 22 | 251 | | created = RSA.Create(requestedKeySizeBits); |
| | 20 | 252 | | } |
| | 2 | 253 | | catch (CryptographicException exception) |
| | | 254 | | { |
| | 2 | 255 | | throw new InvalidOperationException( |
| | 2 | 256 | | $"Local-development RSA key size {requestedKeySizeBits} bits is not supported by the platform RSA provid |
| | 2 | 257 | | exception); |
| | | 258 | | } |
| | | 259 | | |
| | 20 | 260 | | if (created.KeySize != requestedKeySizeBits) |
| | | 261 | | { |
| | 0 | 262 | | int generatedKeySizeBits = created.KeySize; |
| | 0 | 263 | | created.Dispose(); |
| | | 264 | | |
| | 0 | 265 | | throw new InvalidOperationException( |
| | 0 | 266 | | $"Local-development RSA key size {requestedKeySizeBits} bits is not supported by the platform RSA provid |
| | | 267 | | } |
| | | 268 | | |
| | 20 | 269 | | return created; |
| | | 270 | | } |
| | | 271 | | |
| | | 272 | | private static string NormalizeRequired(string? value, string fallback) |
| | | 273 | | { |
| | 166 | 274 | | return string.IsNullOrWhiteSpace(value) |
| | 166 | 275 | | ? fallback |
| | 166 | 276 | | : value.Trim(); |
| | | 277 | | } |
| | | 278 | | |
| | | 279 | | private static string NormalizeHashAlgorithm(string? hashAlgorithm) |
| | | 280 | | { |
| | 56 | 281 | | return string.IsNullOrWhiteSpace(hashAlgorithm) |
| | 56 | 282 | | ? SupportedHashAlgorithm |
| | 56 | 283 | | : hashAlgorithm.Trim().Equals("SHA256", StringComparison.OrdinalIgnoreCase) |
| | 56 | 284 | | ? SupportedHashAlgorithm |
| | 56 | 285 | | : hashAlgorithm.Trim(); |
| | | 286 | | } |
| | | 287 | | |
| | | 288 | | private static bool IsSupportedHashAlgorithm(string? hashAlgorithm) |
| | | 289 | | { |
| | 24 | 290 | | string normalized = NormalizeHashAlgorithm(hashAlgorithm); |
| | | 291 | | |
| | 24 | 292 | | return normalized.Equals(SupportedHashAlgorithm, StringComparison.OrdinalIgnoreCase); |
| | | 293 | | } |
| | | 294 | | |
| | | 295 | | private static bool FixedTimeHashEquals(string? expectedHash, string? actualHash) |
| | | 296 | | { |
| | 8 | 297 | | if (expectedHash is null || actualHash is null) |
| | | 298 | | { |
| | 0 | 299 | | return expectedHash is null && actualHash is null; |
| | | 300 | | } |
| | | 301 | | |
| | 8 | 302 | | byte[] expectedBytes = SigningEncoding.GetBytes(expectedHash); |
| | 8 | 303 | | byte[] actualBytes = SigningEncoding.GetBytes(actualHash); |
| | | 304 | | |
| | 8 | 305 | | return CryptographicOperations.FixedTimeEquals(expectedBytes, actualBytes); |
| | | 306 | | } |
| | | 307 | | |
| | | 308 | | private string? ValidateSigningRequest(SigningRequest request) |
| | | 309 | | { |
| | 33 | 310 | | if (Volatile.Read(ref disposed)) |
| | | 311 | | { |
| | 16 | 312 | | return "localdev.signing.disposed"; |
| | | 313 | | } |
| | | 314 | | |
| | 17 | 315 | | if (!IsSupportedHashAlgorithm(request.HashAlgorithm)) |
| | | 316 | | { |
| | 3 | 317 | | return "localdev.signing.hash-algorithm-unsupported"; |
| | | 318 | | } |
| | | 319 | | |
| | 14 | 320 | | string configuredKeyId = NormalizeRequired(options.KeyId, LocalDevelopmentSigningOptions.DefaultKeyId); |
| | 14 | 321 | | if (request.KeyId is not null && !string.Equals(request.KeyId, configuredKeyId, StringComparison.Ordinal)) |
| | | 322 | | { |
| | 0 | 323 | | return "localdev.signing.key-mismatch"; |
| | | 324 | | } |
| | | 325 | | |
| | 14 | 326 | | string configuredKeyVersion = NormalizeRequired(options.KeyVersion, LocalDevelopmentSigningOptions.DefaultKeyVer |
| | 14 | 327 | | return request.KeyVersion is not null && !string.Equals(request.KeyVersion, configuredKeyVersion, StringComparis |
| | 14 | 328 | | ? "localdev.signing.key-version-mismatch" |
| | 14 | 329 | | : null; |
| | | 330 | | } |
| | | 331 | | |
| | | 332 | | private SigningResult CreateUnsignedFailureResult(SigningRequest request, string failureCode, string failureMessage) |
| | | 333 | | { |
| | 18 | 334 | | Dictionary<string, string> metadata = CreateBaseMetadata(request); |
| | 18 | 335 | | metadata["signing_status"] = "failed"; |
| | 18 | 336 | | metadata["failure_code"] = failureCode; |
| | 18 | 337 | | metadata["failure_message"] = failureMessage; |
| | | 338 | | |
| | 18 | 339 | | var signingMetadata = SigningMetadata.Create( |
| | 18 | 340 | | signingHash: request.SigningHash, |
| | 18 | 341 | | hashAlgorithm: NormalizeHashAlgorithm(request.HashAlgorithm), |
| | 18 | 342 | | keyId: NormalizeRequired(options.KeyId, LocalDevelopmentSigningOptions.DefaultKeyId), |
| | 18 | 343 | | keyVersion: NormalizeRequired(options.KeyVersion, LocalDevelopmentSigningOptions.DefaultKeyVersion), |
| | 18 | 344 | | provider: NormalizeRequired(options.ProviderName, LocalDevelopmentSigningOptions.DefaultProviderName), |
| | 18 | 345 | | metadata: metadata); |
| | | 346 | | |
| | 18 | 347 | | return SigningResult.FromMetadata(signingMetadata); |
| | | 348 | | } |
| | | 349 | | |
| | | 350 | | private Dictionary<string, string> CreateBaseMetadata(SigningRequest request) |
| | | 351 | | { |
| | 32 | 352 | | Dictionary<string, string> metadata = new(StringComparer.Ordinal) |
| | 32 | 353 | | { |
| | 32 | 354 | | ["provider_kind"] = "local-development", |
| | 32 | 355 | | ["provider_warning"] = "local-development-only", |
| | 32 | 356 | | ["key_algorithm"] = "RSA", |
| | 32 | 357 | | ["key_size_bits"] = keySizeBits.ToString(System.Globalization.CultureInfo.InvariantCulture) |
| | 32 | 358 | | }; |
| | | 359 | | |
| | 32 | 360 | | if (request.Purpose is not null) |
| | | 361 | | { |
| | 8 | 362 | | metadata["purpose"] = request.Purpose; |
| | | 363 | | } |
| | | 364 | | |
| | 98 | 365 | | foreach (KeyValuePair<string, string> item in request.Metadata) |
| | | 366 | | { |
| | 17 | 367 | | if (string.IsNullOrWhiteSpace(item.Key)) |
| | | 368 | | { |
| | | 369 | | continue; |
| | | 370 | | } |
| | | 371 | | |
| | 17 | 372 | | metadata[item.Key.Trim()] = item.Value?.Trim() ?? string.Empty; |
| | | 373 | | } |
| | | 374 | | |
| | 32 | 375 | | return metadata; |
| | | 376 | | } |
| | | 377 | | |
| | | 378 | | private void ThrowIfDisposed() |
| | | 379 | | { |
| | 20 | 380 | | ObjectDisposedException.ThrowIf(disposed, this); |
| | 20 | 381 | | } |
| | | 382 | | } |