Files
Marc Schier 0000513cde Pluggable user-identity provider model (#3807)
# Description

Introduces a pluggable user-identity provider model covering all OPC UA
Part 6 §6.5 mechanisms (Anonymous, UserName, X509, IssuedToken/JWT) on
both client and server, with full
`Microsoft.Extensions.DependencyInjection` integration, plus the Part 12
§9 `AuthorizationService` two-phase token-request flow **including
refresh-token rotation**, the Part 12 §8 KeyCredential Push model, and a
clean provider model for OPC 10000-12 §7.2 `ApplicationSelfAdmin`. The
work is split across phase-by-phase commits so it can be reviewed
independently; nothing here changes wire-level behaviour or forces
existing applications to migrate.

### Documentation

All under `Docs/`:

- `IdentityProviders.md` — full developer guide: three-layer model,
recipes, configuration reference, "what gets auto-wired" matrix, JWS
algorithm table, identity-augmenters section, **"Implementing your own
provider"** how-to subsections for Entra / OIDC / Windows / AspNetCore
(sibling files merged in per review feedback).
- `DependencyInjection.md` — `Identity` sections with `appsettings.json`
examples, identity-extension matrix, `WithAuthorizationService` +
`WithKeyCredentialPush` + `AddIdentityAugmenter` extensions documented.
- `RoleBasedUserManagement.md` — claim-criteria callout describing
`IIdentityClaims` probe surface.
- `AuthorizationService.md` — Architecture diagram +
`IAccessTokenProvider` conceptual narrative restored from master; modern
`StartRequestToken` / `FinishRequestToken` flow leads; refresh-token
recipe added.
- `KeyCredentialService.md` — Push model + Pull vs Push vs Bridge
decision matrix + Hybrid (Push + Pull) section; explicit experimental
warning for the bridge.
- `GDS.md` — KeyCredential Push, `StartRequestToken`,
`GdsApplicationSelfAdminProvider`, **shipped Refresh-token support with
rotation policy and audit semantics** all documented.
- `MigrationGuide.md` — User Identity Providers section listing
`[Obsolete]` markers + mechanical recipes; SelfAdmin migration via
`IIdentityAugmenter`; `EnableRefreshTokens` default note.
- `README.md` index entries.

## Checklist

- [x] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [x] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed.
- [x] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [x] I have addressed **all** PR feedback received.
2026-06-02 01:14:54 +02:00

426 lines
16 KiB
C#

/* ========================================================================
* Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
using System;
using System.IO;
using System.Security.Cryptography;
using Microsoft.Extensions.Logging;
using Opc.Ua.Security.Certificates;
namespace Opc.Ua
{
/// <summary>
/// Defines internal functions to implement RSA cryptography.
/// </summary>
internal static class RsaUtils
{
public enum Padding
{
Pkcs1,
OaepSHA1,
OaepSHA256
}
internal static RSAEncryptionPadding GetRSAEncryptionPadding(Padding padding)
{
switch (padding)
{
case Padding.Pkcs1:
return RSAEncryptionPadding.Pkcs1;
case Padding.OaepSHA1:
return RSAEncryptionPadding.OaepSHA1;
case Padding.OaepSHA256:
return RSAEncryptionPadding.OaepSHA256;
default:
throw ServiceResultException.Unexpected($"Unexpected Padding {padding}");
}
}
/// <summary>
/// Return the plaintext block size for RSA OAEP encryption.
/// </summary>
internal static int GetPlainTextBlockSize(
Certificate encryptingCertificate,
Padding padding)
{
using RSA? rsa = encryptingCertificate.GetRSAPublicKey();
return GetPlainTextBlockSize(rsa, padding);
}
/// <summary>
/// Return the plaintext block size for RSA OAEP encryption.
/// </summary>
/// <exception cref="ServiceResultException"></exception>
internal static int GetPlainTextBlockSize(RSA? rsa, Padding padding)
{
if (rsa != null)
{
switch (padding)
{
case Padding.Pkcs1:
return (rsa.KeySize / 8) - 11;
case Padding.OaepSHA1:
return (rsa.KeySize / 8) - 42;
case Padding.OaepSHA256:
return (rsa.KeySize / 8) - 66;
default:
throw ServiceResultException.Unexpected($"Unexpected Padding {padding}");
}
}
return -1;
}
/// <summary>
/// Return the ciphertext block size for RSA OAEP encryption.
/// </summary>
internal static int GetCipherTextBlockSize(Certificate encryptingCertificate)
{
using RSA? rsa = encryptingCertificate.GetRSAPublicKey();
return GetCipherTextBlockSize(rsa);
}
/// <summary>
/// Return the ciphertext block size for RSA OAEP encryption.
/// </summary>
internal static int GetCipherTextBlockSize(RSA? rsa)
{
if (rsa != null)
{
return rsa.KeySize / 8;
}
return -1;
}
/// <summary>
/// Returns the length of a RSA PKCS#1 v1.5 signature of a digest.
/// </summary>
/// <exception cref="ServiceResultException"></exception>
internal static int GetSignatureLength(Certificate signingCertificate)
{
using RSA rsa =
signingCertificate.GetRSAPublicKey()
?? throw ServiceResultException.Create(
StatusCodes.BadSecurityChecksFailed,
"No public key for certificate.");
return rsa.KeySize / 8;
}
/// <summary>
/// Computes a RSA signature.
/// </summary>
/// <exception cref="ServiceResultException"></exception>
internal static byte[] Rsa_Sign(
ArraySegment<byte> dataToSign,
Certificate signingCertificate,
HashAlgorithmName hashAlgorithm,
RSASignaturePadding rsaSignaturePadding)
{
// extract the private key.
using RSA rsa =
signingCertificate.GetRSAPrivateKey()
?? throw ServiceResultException.Create(
StatusCodes.BadSecurityChecksFailed,
"No private key for certificate.");
// create the signature.
return rsa.SignData(
dataToSign.GetArray(),
dataToSign.Offset,
dataToSign.Count,
hashAlgorithm,
rsaSignaturePadding);
}
/// <summary>
/// Verifies a RSA signature.
/// </summary>
/// <exception cref="ServiceResultException"></exception>
internal static bool Rsa_Verify(
ArraySegment<byte> dataToVerify,
byte[] signature,
Certificate signingCertificate,
HashAlgorithmName hashAlgorithm,
RSASignaturePadding rsaSignaturePadding)
{
// extract the public key.
using RSA rsa =
signingCertificate.GetRSAPublicKey()
?? throw ServiceResultException.Create(
StatusCodes.BadSecurityChecksFailed,
"No public key for certificate.");
// verify signature.
return rsa.VerifyData(
dataToVerify.GetArray(),
dataToVerify.Offset,
dataToVerify.Count,
signature,
hashAlgorithm,
rsaSignaturePadding);
}
/// <summary>
/// Encrypts the data using RSA encryption.
/// </summary>
/// <exception cref="ServiceResultException"></exception>
internal static byte[] Encrypt(
ReadOnlySpan<byte> dataToEncrypt,
Certificate encryptingCertificate,
Padding padding,
ILogger logger)
{
using RSA rsa =
encryptingCertificate.GetRSAPublicKey()
?? throw ServiceResultException.Create(
StatusCodes.BadSecurityChecksFailed,
"No public key for certificate.");
int plaintextBlockSize = GetPlainTextBlockSize(rsa, padding);
int blockCount = ((dataToEncrypt.Length + 4) / plaintextBlockSize) + 1;
int plainTextSize = blockCount * plaintextBlockSize;
int cipherTextSize = blockCount * GetCipherTextBlockSize(rsa);
byte[] plainText = new byte[plainTextSize];
// encode length.
plainText[0] = (byte)(0x000000FF & dataToEncrypt.Length);
plainText[1] = (byte)((0x0000FF00 & dataToEncrypt.Length) >> 8);
plainText[2] = (byte)((0x00FF0000 & dataToEncrypt.Length) >> 16);
plainText[3] = (byte)((0xFF000000 & dataToEncrypt.Length) >> 24);
// copy data.
dataToEncrypt.CopyTo(plainText.AsSpan(4, dataToEncrypt.Length));
byte[] buffer = new byte[cipherTextSize];
ArraySegment<byte> cipherText = Encrypt(
new ArraySegment<byte>(plainText),
rsa,
padding,
new ArraySegment<byte>(buffer),
logger);
System.Diagnostics.Debug.Assert(cipherText.Count == buffer.Length);
Array.Clear(plainText, 0, plainText.Length);
return buffer;
}
/// <summary>
/// Encrypts the data using RSA PKCS#1 v1.5 or OAEP encryption.
/// </summary>
private static ArraySegment<byte> Encrypt(
ArraySegment<byte> dataToEncrypt,
RSA rsa,
Padding padding,
ArraySegment<byte> outputBuffer,
ILogger logger)
{
int inputBlockSize = GetPlainTextBlockSize(rsa, padding);
int outputBlockSize = GetCipherTextBlockSize(rsa);
// verify the input data is the correct block size.
if (dataToEncrypt.Count % inputBlockSize != 0)
{
logger.LogError(
"Message is not an integral multiple of the block size. Length = {Length}, BlockSize = {BlockSize}.",
dataToEncrypt.Count,
inputBlockSize);
}
byte[]? encryptedBuffer = outputBuffer.Array;
RSAEncryptionPadding rsaPadding = GetRSAEncryptionPadding(padding);
using (var ostrm = new MemoryStream(
encryptedBuffer!,
outputBuffer.Offset,
outputBuffer.Count))
{
// encrypt body.
byte[] input = new byte[inputBlockSize];
for (int ii = dataToEncrypt.Offset;
ii < dataToEncrypt.Offset + dataToEncrypt.Count;
ii += inputBlockSize)
{
Array.Copy(dataToEncrypt.GetArray(), ii, input, 0, input.Length);
byte[] cipherText = rsa.Encrypt(input, rsaPadding);
ostrm.Write(cipherText, 0, cipherText.Length);
}
}
// return buffer
return new ArraySegment<byte>(
encryptedBuffer!,
outputBuffer.Offset,
dataToEncrypt.Count / inputBlockSize * outputBlockSize);
}
/// <summary>
/// Decrypts the data using RSA encryption.
/// </summary>
/// <exception cref="ServiceResultException"></exception>
internal static byte[] Decrypt(
ArraySegment<byte> dataToDecrypt,
Certificate encryptingCertificate,
Padding padding,
ILogger logger)
{
using RSA rsa =
encryptingCertificate.GetRSAPrivateKey()
?? throw ServiceResultException.Create(
StatusCodes.BadSecurityChecksFailed,
"No private key for certificate.");
int plainTextSize = dataToDecrypt.Count / GetCipherTextBlockSize(rsa);
plainTextSize *= GetPlainTextBlockSize(encryptingCertificate, padding);
byte[] buffer = new byte[plainTextSize];
ArraySegment<byte> plainText = Decrypt(
dataToDecrypt,
rsa,
padding,
new ArraySegment<byte>(buffer),
logger);
System.Diagnostics.Debug.Assert(plainText.Count == buffer.Length);
// decode length.
int length = 0;
byte[] plainTextArray = plainText.GetArray();
length += plainTextArray[plainText.Offset + 0];
length += plainTextArray[plainText.Offset + 1] << 8;
length += plainTextArray[plainText.Offset + 2] << 16;
length += plainTextArray[plainText.Offset + 3] << 24;
if (length > (plainText.Count - plainText.Offset - 4))
{
throw ServiceResultException.Create(
StatusCodes.BadEndOfStream,
"Could not decrypt data. Invalid total length.");
}
byte[] decryptedData = new byte[length];
Array.Copy(plainTextArray, plainText.Offset + 4, decryptedData, 0, length);
Array.Clear(buffer, 0, buffer.Length);
return decryptedData;
}
/// <summary>
/// Decrypts the message using RSA encryption.
/// </summary>
private static ArraySegment<byte> Decrypt(
ArraySegment<byte> dataToDecrypt,
RSA rsa,
Padding padding,
ArraySegment<byte> outputBuffer,
ILogger logger)
{
int inputBlockSize = GetCipherTextBlockSize(rsa);
int outputBlockSize = GetPlainTextBlockSize(rsa, padding);
// verify the input data is the correct block size.
if (dataToDecrypt.Count % inputBlockSize != 0)
{
logger.LogError(
"Message is not an integral multiple of the block size. Length = {Length}, BlockSize = {BlockSize}.",
dataToDecrypt.Count,
inputBlockSize);
}
byte[]? decryptedBuffer = outputBuffer.Array;
RSAEncryptionPadding rsaPadding = GetRSAEncryptionPadding(padding);
using (var ostrm = new MemoryStream(
decryptedBuffer!,
outputBuffer.Offset,
outputBuffer.Count))
{
// decrypt body.
byte[] input = new byte[inputBlockSize];
for (int ii = dataToDecrypt.Offset;
ii < dataToDecrypt.Offset + dataToDecrypt.Count;
ii += inputBlockSize)
{
Array.Copy(dataToDecrypt.GetArray(), ii, input, 0, input.Length);
byte[] plainText = rsa.Decrypt(input, rsaPadding);
ostrm.Write(plainText, 0, plainText.Length);
}
}
// return buffers.
return new ArraySegment<byte>(
decryptedBuffer!,
outputBuffer.Offset,
dataToDecrypt.Count / inputBlockSize * outputBlockSize);
}
/// <summary>
/// Helper to test for RSASignaturePadding.Pss support, some platforms do not support it.
/// </summary>
internal static bool TryVerifyRSAPssSign(RSA publicKey, RSA privateKey)
{
try
{
var randomSource = new Test.RandomSource();
const int blockSize = 0x10;
byte[] testBlock = new byte[blockSize];
randomSource.NextBytes(testBlock, 0, blockSize);
byte[] signature = privateKey.SignData(
testBlock,
HashAlgorithmName.SHA256,
RSASignaturePadding.Pss);
return publicKey.VerifyData(
testBlock,
signature,
HashAlgorithmName.SHA256,
RSASignaturePadding.Pss);
}
catch
{
return false;
}
}
/// <summary>
/// Lazy helper to allow runtime to check for Pss support.
/// </summary>
internal static readonly Lazy<bool> IsSupportingRSAPssSign = new(() =>
{
#if NETFRAMEWORK
// The Pss check returns false on .Net4.6/4.7, although it is always supported with certs.
// but not supported with Mono
return !Utils.IsRunningOnMono();
#else
using var rsa = RSA.Create();
return TryVerifyRSAPssSign(rsa, rsa);
#endif
});
}
}