feat(lmxproxy): phase 1 — v2 protocol types and domain model

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-03-21 23:41:56 -04:00
parent 08d2a07d8b
commit 0d63fb1105
87 changed files with 3389 additions and 956 deletions

View File

@@ -0,0 +1,49 @@
namespace ZB.MOM.WW.LmxProxy.Host.Security
{
/// <summary>
/// Represents an API key with associated permissions
/// </summary>
public class ApiKey
{
/// <summary>
/// The API key value
/// </summary>
public string Key { get; set; } = string.Empty;
/// <summary>
/// Description of what this API key is used for
/// </summary>
public string Description { get; set; } = string.Empty;
/// <summary>
/// The role assigned to this API key
/// </summary>
public ApiKeyRole Role { get; set; } = ApiKeyRole.ReadOnly;
/// <summary>
/// Whether this API key is enabled
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Checks if the API key is valid
/// </summary>
public bool IsValid() => Enabled;
}
/// <summary>
/// API key roles
/// </summary>
public enum ApiKeyRole
{
/// <summary>
/// Can only read data
/// </summary>
ReadOnly,
/// <summary>
/// Can read and write data
/// </summary>
ReadWrite
}
}

View File

@@ -0,0 +1,15 @@
using System.Collections.Generic;
namespace ZB.MOM.WW.LmxProxy.Host.Security
{
/// <summary>
/// Configuration for API keys loaded from file
/// </summary>
public class ApiKeyConfiguration
{
/// <summary>
/// List of API keys
/// </summary>
public List<ApiKey> ApiKeys { get; set; } = new();
}
}

View File

@@ -0,0 +1,168 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using Grpc.Core;
using Grpc.Core.Interceptors;
using Serilog;
namespace ZB.MOM.WW.LmxProxy.Host.Security
{
/// <summary>
/// gRPC interceptor for API key authentication.
/// Validates API keys for incoming requests and enforces role-based access control.
/// </summary>
public class ApiKeyInterceptor : Interceptor
{
private static readonly ILogger Logger = Log.ForContext<ApiKeyInterceptor>();
/// <summary>
/// List of gRPC method names that require write access.
/// </summary>
private static readonly string[] WriteMethodNames =
{
"Write",
"WriteBatch",
"WriteBatchAndWait"
};
private readonly ApiKeyService _apiKeyService;
/// <summary>
/// Initializes a new instance of the <see cref="ApiKeyInterceptor" /> class.
/// </summary>
/// <param name="apiKeyService">The API key service used for validation.</param>
/// <exception cref="ArgumentNullException">Thrown if <paramref name="apiKeyService" /> is null.</exception>
public ApiKeyInterceptor(ApiKeyService apiKeyService)
{
_apiKeyService = apiKeyService ?? throw new ArgumentNullException(nameof(apiKeyService));
}
/// <summary>
/// Handles unary gRPC calls, validating API key and enforcing permissions.
/// </summary>
/// <typeparam name="TRequest">The request type.</typeparam>
/// <typeparam name="TResponse">The response type.</typeparam>
/// <param name="request">The request message.</param>
/// <param name="context">The server call context.</param>
/// <param name="continuation">The continuation delegate.</param>
/// <returns>The response message.</returns>
/// <exception cref="RpcException">Thrown if authentication or authorization fails.</exception>
public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
TRequest request,
ServerCallContext context,
UnaryServerMethod<TRequest, TResponse> continuation)
{
string apiKey = GetApiKeyFromContext(context);
string methodName = GetMethodName(context.Method);
if (string.IsNullOrEmpty(apiKey))
{
Logger.Warning("Missing API key for method {Method} from {Peer}",
context.Method, context.Peer);
throw new RpcException(new Status(StatusCode.Unauthenticated, "API key is required"));
}
ApiKey? key = _apiKeyService.ValidateApiKey(apiKey);
if (key == null)
{
Logger.Warning("Invalid API key for method {Method} from {Peer}",
context.Method, context.Peer);
throw new RpcException(new Status(StatusCode.Unauthenticated, "Invalid API key"));
}
// Check if method requires write access
if (IsWriteMethod(methodName) && key.Role != ApiKeyRole.ReadWrite)
{
Logger.Warning("Insufficient permissions for method {Method} with API key {Description}",
context.Method, key.Description);
throw new RpcException(new Status(StatusCode.PermissionDenied,
"API key does not have write permissions"));
}
// Add API key info to context items for use in service methods
context.UserState["ApiKey"] = key;
Logger.Debug("Authorized method {Method} for API key {Description}",
context.Method, key.Description);
return await continuation(request, context);
}
/// <summary>
/// Handles server streaming gRPC calls, validating API key and enforcing permissions.
/// </summary>
/// <typeparam name="TRequest">The request type.</typeparam>
/// <typeparam name="TResponse">The response type.</typeparam>
/// <param name="request">The request message.</param>
/// <param name="responseStream">The response stream writer.</param>
/// <param name="context">The server call context.</param>
/// <param name="continuation">The continuation delegate.</param>
/// <returns>A task representing the asynchronous operation.</returns>
/// <exception cref="RpcException">Thrown if authentication fails.</exception>
public override async Task ServerStreamingServerHandler<TRequest, TResponse>(
TRequest request,
IServerStreamWriter<TResponse> responseStream,
ServerCallContext context,
ServerStreamingServerMethod<TRequest, TResponse> continuation)
{
string apiKey = GetApiKeyFromContext(context);
if (string.IsNullOrEmpty(apiKey))
{
Logger.Warning("Missing API key for streaming method {Method} from {Peer}",
context.Method, context.Peer);
throw new RpcException(new Status(StatusCode.Unauthenticated, "API key is required"));
}
ApiKey? key = _apiKeyService.ValidateApiKey(apiKey);
if (key == null)
{
Logger.Warning("Invalid API key for streaming method {Method} from {Peer}",
context.Method, context.Peer);
throw new RpcException(new Status(StatusCode.Unauthenticated, "Invalid API key"));
}
// Add API key info to context items
context.UserState["ApiKey"] = key;
Logger.Debug("Authorized streaming method {Method} for API key {Description}",
context.Method, key.Description);
await continuation(request, responseStream, context);
}
/// <summary>
/// Extracts the API key from the gRPC request headers.
/// </summary>
/// <param name="context">The server call context.</param>
/// <returns>The API key value, or an empty string if not found.</returns>
private static string GetApiKeyFromContext(ServerCallContext context)
{
// Check for API key in metadata (headers)
Metadata.Entry? entry = context.RequestHeaders.FirstOrDefault(e =>
e.Key.Equals("x-api-key", StringComparison.OrdinalIgnoreCase));
return entry?.Value ?? string.Empty;
}
/// <summary>
/// Gets the method name from the full gRPC method string.
/// </summary>
/// <param name="method">The full method string (e.g., /package.Service/Method).</param>
/// <returns>The method name.</returns>
private static string GetMethodName(string method)
{
// Method format is /package.Service/Method
int lastSlash = method.LastIndexOf('/');
return lastSlash >= 0 ? method.Substring(lastSlash + 1) : method;
}
/// <summary>
/// Determines whether the specified method name requires write access.
/// </summary>
/// <param name="methodName">The method name.</param>
/// <returns><c>true</c> if the method requires write access; otherwise, <c>false</c>.</returns>
private static bool IsWriteMethod(string methodName) =>
WriteMethodNames.Contains(methodName, StringComparer.OrdinalIgnoreCase);
}
}

View File

@@ -0,0 +1,305 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Serilog;
namespace ZB.MOM.WW.LmxProxy.Host.Security
{
/// <summary>
/// Service for managing API keys with file-based storage.
/// Handles validation, role checking, and automatic reload on file changes.
/// </summary>
public class ApiKeyService : IDisposable
{
private static readonly ILogger Logger = Log.ForContext<ApiKeyService>();
private readonly ConcurrentDictionary<string, ApiKey> _apiKeys;
private readonly string _configFilePath;
private readonly SemaphoreSlim _reloadLock = new(1, 1);
private bool _disposed;
private FileSystemWatcher? _fileWatcher;
private DateTime _lastReloadTime = DateTime.MinValue;
/// <summary>
/// Initializes a new instance of the <see cref="ApiKeyService" /> class.
/// </summary>
/// <param name="configFilePath">The path to the API key configuration file.</param>
/// <exception cref="ArgumentNullException">Thrown if <paramref name="configFilePath" /> is null.</exception>
public ApiKeyService(string configFilePath)
{
_configFilePath = configFilePath ?? throw new ArgumentNullException(nameof(configFilePath));
_apiKeys = new ConcurrentDictionary<string, ApiKey>();
InitializeFileWatcher();
LoadConfiguration();
}
/// <summary>
/// Disposes the <see cref="ApiKeyService" /> and releases resources.
/// </summary>
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
_fileWatcher?.Dispose();
_reloadLock?.Dispose();
Logger.Information("API key service disposed");
}
/// <summary>
/// Validates an API key and returns its details if valid.
/// </summary>
/// <param name="apiKey">The API key value to validate.</param>
/// <returns>The <see cref="ApiKey" /> if valid; otherwise, <c>null</c>.</returns>
public ApiKey? ValidateApiKey(string apiKey)
{
if (string.IsNullOrWhiteSpace(apiKey))
{
return null;
}
if (_apiKeys.TryGetValue(apiKey, out ApiKey? key) && key.IsValid())
{
Logger.Debug("API key validated successfully for {Description}", key.Description);
return key;
}
Logger.Warning("Invalid or expired API key attempted");
return null;
}
/// <summary>
/// Checks if an API key has the specified role.
/// </summary>
/// <param name="apiKey">The API key value.</param>
/// <param name="requiredRole">The required <see cref="ApiKeyRole" />.</param>
/// <returns><c>true</c> if the API key has the required role; otherwise, <c>false</c>.</returns>
public bool HasRole(string apiKey, ApiKeyRole requiredRole)
{
ApiKey? key = ValidateApiKey(apiKey);
if (key == null)
{
return false;
}
// ReadWrite role has access to everything
if (key.Role == ApiKeyRole.ReadWrite)
{
return true;
}
// ReadOnly role only has access to ReadOnly operations
return requiredRole == ApiKeyRole.ReadOnly;
}
/// <summary>
/// Initializes the file system watcher for the API key configuration file.
/// </summary>
private void InitializeFileWatcher()
{
string? directory = Path.GetDirectoryName(_configFilePath);
string? fileName = Path.GetFileName(_configFilePath);
if (string.IsNullOrEmpty(directory) || string.IsNullOrEmpty(fileName))
{
Logger.Warning("Invalid config file path, file watching disabled");
return;
}
try
{
_fileWatcher = new FileSystemWatcher(directory, fileName)
{
NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size | NotifyFilters.CreationTime,
EnableRaisingEvents = true
};
_fileWatcher.Changed += OnFileChanged;
_fileWatcher.Created += OnFileChanged;
_fileWatcher.Renamed += OnFileRenamed;
Logger.Information("File watcher initialized for {FilePath}", _configFilePath);
}
catch (Exception ex)
{
Logger.Error(ex, "Failed to initialize file watcher for {FilePath}", _configFilePath);
}
}
/// <summary>
/// Handles file change events for the configuration file.
/// </summary>
/// <param name="sender">The event sender.</param>
/// <param name="e">The <see cref="FileSystemEventArgs" /> instance containing event data.</param>
private void OnFileChanged(object sender, FileSystemEventArgs e)
{
if (e.ChangeType == WatcherChangeTypes.Changed || e.ChangeType == WatcherChangeTypes.Created)
{
Logger.Information("API key configuration file changed, reloading");
Task.Run(() => ReloadConfigurationAsync());
}
}
/// <summary>
/// Handles file rename events for the configuration file.
/// </summary>
/// <param name="sender">The event sender.</param>
/// <param name="e">The <see cref="RenamedEventArgs" /> instance containing event data.</param>
private void OnFileRenamed(object sender, RenamedEventArgs e)
{
if (e.FullPath.Equals(_configFilePath, StringComparison.OrdinalIgnoreCase))
{
Logger.Information("API key configuration file renamed, reloading");
Task.Run(() => ReloadConfigurationAsync());
}
}
/// <summary>
/// Asynchronously reloads the API key configuration from file.
/// Debounces rapid file changes to avoid excessive reloads.
/// </summary>
private async Task ReloadConfigurationAsync()
{
// Debounce rapid file changes
TimeSpan timeSinceLastReload = DateTime.UtcNow - _lastReloadTime;
if (timeSinceLastReload < TimeSpan.FromSeconds(1))
{
await Task.Delay(TimeSpan.FromSeconds(1) - timeSinceLastReload);
}
await _reloadLock.WaitAsync();
try
{
LoadConfiguration();
_lastReloadTime = DateTime.UtcNow;
}
finally
{
_reloadLock.Release();
}
}
/// <summary>
/// Loads the API key configuration from file.
/// If the file does not exist, creates a default configuration.
/// </summary>
private void LoadConfiguration()
{
try
{
if (!File.Exists(_configFilePath))
{
Logger.Warning("API key configuration file not found at {FilePath}, creating default",
_configFilePath);
CreateDefaultConfiguration();
return;
}
string json = File.ReadAllText(_configFilePath);
var options = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip
};
options.Converters.Add(new JsonStringEnumConverter());
ApiKeyConfiguration? config = JsonSerializer.Deserialize<ApiKeyConfiguration>(json, options);
if (config?.ApiKeys == null || !config.ApiKeys.Any())
{
Logger.Warning("No API keys found in configuration file");
return;
}
// Clear existing keys and load new ones
_apiKeys.Clear();
foreach (ApiKey? apiKey in config.ApiKeys)
{
if (string.IsNullOrWhiteSpace(apiKey.Key))
{
Logger.Warning("Skipping API key with empty key value");
continue;
}
if (_apiKeys.TryAdd(apiKey.Key, apiKey))
{
Logger.Information("Loaded API key: {Description} with role {Role}",
apiKey.Description, apiKey.Role);
}
else
{
Logger.Warning("Duplicate API key found: {Description}", apiKey.Description);
}
}
Logger.Information("Loaded {Count} API keys from configuration", _apiKeys.Count);
}
catch (Exception ex)
{
Logger.Error(ex, "Failed to load API key configuration from {FilePath}", _configFilePath);
}
}
/// <summary>
/// Creates a default API key configuration file with sample keys.
/// </summary>
private void CreateDefaultConfiguration()
{
try
{
var defaultConfig = new ApiKeyConfiguration
{
ApiKeys = new List<ApiKey>
{
new()
{
Key = Guid.NewGuid().ToString("N"),
Description = "Default read-only API key",
Role = ApiKeyRole.ReadOnly,
Enabled = true
},
new()
{
Key = Guid.NewGuid().ToString("N"),
Description = "Default read-write API key",
Role = ApiKeyRole.ReadWrite,
Enabled = true
}
}
};
string? json = JsonSerializer.Serialize(defaultConfig, new JsonSerializerOptions
{
WriteIndented = true
});
string? directory = Path.GetDirectoryName(_configFilePath);
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}
File.WriteAllText(_configFilePath, json);
Logger.Information("Created default API key configuration at {FilePath}", _configFilePath);
// Load the created configuration
LoadConfiguration();
}
catch (Exception ex)
{
Logger.Error(ex, "Failed to create default API key configuration");
}
}
}
}

View File

@@ -0,0 +1,329 @@
using System;
using System.IO;
using System.Net;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using Serilog;
using ZB.MOM.WW.LmxProxy.Host.Configuration;
namespace ZB.MOM.WW.LmxProxy.Host.Security
{
/// <summary>
/// Manages TLS certificates for the LmxProxy service, including generation and validation
/// </summary>
public class TlsCertificateManager
{
private static readonly ILogger Logger = Log.ForContext<TlsCertificateManager>();
private readonly TlsConfiguration _tlsConfiguration;
public TlsCertificateManager(TlsConfiguration tlsConfiguration)
{
_tlsConfiguration = tlsConfiguration ?? throw new ArgumentNullException(nameof(tlsConfiguration));
}
/// <summary>
/// Checks TLS certificate status and creates new certificates if needed
/// </summary>
/// <returns>True if certificates are valid or were successfully created</returns>
public bool EnsureCertificatesValid()
{
if (!_tlsConfiguration.Enabled)
{
Logger.Information("TLS is disabled, skipping certificate check");
return true;
}
try
{
// Check if certificate files exist
bool certificateExists = File.Exists(_tlsConfiguration.ServerCertificatePath);
bool keyExists = File.Exists(_tlsConfiguration.ServerKeyPath);
if (!certificateExists || !keyExists)
{
Logger.Warning("TLS certificate or key not found, generating new certificate");
return GenerateNewCertificate();
}
// Check certificate expiration
if (IsCertificateExpiringSoon(_tlsConfiguration.ServerCertificatePath))
{
Logger.Warning("TLS certificate is expiring within the next year, generating new certificate");
return GenerateNewCertificate();
}
Logger.Information("TLS certificate is valid");
return true;
}
catch (Exception ex)
{
Logger.Error(ex, "Error checking TLS certificates");
return false;
}
}
/// <summary>
/// Checks if a certificate is expiring within the next year
/// </summary>
private bool IsCertificateExpiringSoon(string certificatePath)
{
try
{
string certPem = File.ReadAllText(certificatePath);
byte[] certBytes = GetBytesFromPem(certPem, "CERTIFICATE");
using var cert = new X509Certificate2(certBytes);
DateTime expirationDate = cert.NotAfter;
double daysUntilExpiration = (expirationDate - DateTime.Now).TotalDays;
Logger.Information("Certificate expires on {ExpirationDate} ({DaysUntilExpiration:F0} days from now)",
expirationDate, daysUntilExpiration);
// Check if expiring within the next year (365 days)
return daysUntilExpiration <= 365;
}
catch (Exception ex)
{
Logger.Error(ex, "Error checking certificate expiration");
// If we can't check expiration, assume it needs renewal
return true;
}
}
/// <summary>
/// Generates a new self-signed certificate
/// </summary>
private bool GenerateNewCertificate()
{
try
{
Logger.Information("Generating new self-signed TLS certificate");
// Ensure directory exists
string? certDir = Path.GetDirectoryName(_tlsConfiguration.ServerCertificatePath);
if (!string.IsNullOrEmpty(certDir) && !Directory.Exists(certDir))
{
Directory.CreateDirectory(certDir);
Logger.Information("Created certificate directory: {Directory}", certDir);
}
// Generate a new self-signed certificate
using var rsa = RSA.Create(2048);
var request = new CertificateRequest(
"CN=LmxProxy, O=SCADA Bridge, C=US",
rsa,
HashAlgorithmName.SHA256,
RSASignaturePadding.Pkcs1);
// Add certificate extensions
request.CertificateExtensions.Add(
new X509BasicConstraintsExtension(false, false, 0, false));
request.CertificateExtensions.Add(
new X509KeyUsageExtension(
X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment,
false));
request.CertificateExtensions.Add(
new X509EnhancedKeyUsageExtension(
new OidCollection
{
new Oid("1.3.6.1.5.5.7.3.1") // Server Authentication
},
false));
// Add Subject Alternative Names
var sanBuilder = new SubjectAlternativeNameBuilder();
sanBuilder.AddDnsName("localhost");
sanBuilder.AddDnsName(Environment.MachineName);
sanBuilder.AddIpAddress(IPAddress.Loopback);
sanBuilder.AddIpAddress(IPAddress.IPv6Loopback);
request.CertificateExtensions.Add(sanBuilder.Build());
// Create the certificate with 2-year validity
DateTimeOffset notBefore = DateTimeOffset.Now.AddDays(-1);
DateTimeOffset notAfter = DateTimeOffset.Now.AddYears(2);
using X509Certificate2? cert = request.CreateSelfSigned(notBefore, notAfter);
// Export certificate to PEM format
string certPem = ExportCertificateToPem(cert);
File.WriteAllText(_tlsConfiguration.ServerCertificatePath, certPem);
Logger.Information("Saved certificate to {Path}", _tlsConfiguration.ServerCertificatePath);
// Export private key to PEM format
string keyPem = ExportPrivateKeyToPem(rsa);
File.WriteAllText(_tlsConfiguration.ServerKeyPath, keyPem);
Logger.Information("Saved private key to {Path}", _tlsConfiguration.ServerKeyPath);
// If client CA path is specified and doesn't exist, create it
if (!string.IsNullOrWhiteSpace(_tlsConfiguration.ClientCaCertificatePath) &&
!File.Exists(_tlsConfiguration.ClientCaCertificatePath))
{
// For self-signed certificates, the CA cert is the same as the server cert
File.WriteAllText(_tlsConfiguration.ClientCaCertificatePath, certPem);
Logger.Information("Saved CA certificate to {Path}", _tlsConfiguration.ClientCaCertificatePath);
}
Logger.Information("Successfully generated new TLS certificate valid until {NotAfter}", notAfter);
return true;
}
catch (Exception ex)
{
Logger.Error(ex, "Failed to generate new TLS certificate");
return false;
}
}
/// <summary>
/// Exports a certificate to PEM format
/// </summary>
private static string ExportCertificateToPem(X509Certificate2 cert)
{
var builder = new StringBuilder();
builder.AppendLine("-----BEGIN CERTIFICATE-----");
builder.AppendLine(Convert.ToBase64String(cert.Export(X509ContentType.Cert),
Base64FormattingOptions.InsertLineBreaks));
builder.AppendLine("-----END CERTIFICATE-----");
return builder.ToString();
}
/// <summary>
/// Exports an RSA private key to PEM format
/// </summary>
private static string ExportPrivateKeyToPem(RSA rsa)
{
var builder = new StringBuilder();
builder.AppendLine("-----BEGIN RSA PRIVATE KEY-----");
// For .NET Framework 4.8, we need to use the older export method
RSAParameters parameters = rsa.ExportParameters(true);
byte[] keyBytes = EncodeRSAPrivateKey(parameters);
builder.AppendLine(Convert.ToBase64String(keyBytes, Base64FormattingOptions.InsertLineBreaks));
builder.AppendLine("-----END RSA PRIVATE KEY-----");
return builder.ToString();
}
/// <summary>
/// Encodes RSA parameters to PKCS#1 format for .NET Framework 4.8
/// </summary>
private static byte[] EncodeRSAPrivateKey(RSAParameters parameters)
{
using (var stream = new MemoryStream())
using (var writer = new BinaryWriter(stream))
{
// Write version
writer.Write((byte)0x02); // INTEGER
writer.Write((byte)0x01); // Length
writer.Write((byte)0x00); // Version
// Write modulus
WriteIntegerBytes(writer, parameters.Modulus);
// Write public exponent
WriteIntegerBytes(writer, parameters.Exponent);
// Write private exponent
WriteIntegerBytes(writer, parameters.D);
// Write prime1
WriteIntegerBytes(writer, parameters.P);
// Write prime2
WriteIntegerBytes(writer, parameters.Q);
// Write exponent1
WriteIntegerBytes(writer, parameters.DP);
// Write exponent2
WriteIntegerBytes(writer, parameters.DQ);
// Write coefficient
WriteIntegerBytes(writer, parameters.InverseQ);
byte[] innerBytes = stream.ToArray();
// Create SEQUENCE wrapper
using (var finalStream = new MemoryStream())
using (var finalWriter = new BinaryWriter(finalStream))
{
finalWriter.Write((byte)0x30); // SEQUENCE
WriteLength(finalWriter, innerBytes.Length);
finalWriter.Write(innerBytes);
return finalStream.ToArray();
}
}
}
private static void WriteIntegerBytes(BinaryWriter writer, byte[] bytes)
{
if (bytes == null)
{
bytes = new byte[] { 0 };
}
writer.Write((byte)0x02); // INTEGER
if (bytes[0] >= 0x80)
{
// Add padding byte for positive number
WriteLength(writer, bytes.Length + 1);
writer.Write((byte)0x00);
writer.Write(bytes);
}
else
{
WriteLength(writer, bytes.Length);
writer.Write(bytes);
}
}
private static void WriteLength(BinaryWriter writer, int length)
{
if (length < 0x80)
{
writer.Write((byte)length);
}
else if (length <= 0xFF)
{
writer.Write((byte)0x81);
writer.Write((byte)length);
}
else
{
writer.Write((byte)0x82);
writer.Write((byte)(length >> 8));
writer.Write((byte)(length & 0xFF));
}
}
/// <summary>
/// Extracts bytes from PEM format
/// </summary>
private static byte[] GetBytesFromPem(string pem, string section)
{
string header = $"-----BEGIN {section}-----";
string footer = $"-----END {section}-----";
int start = pem.IndexOf(header, StringComparison.Ordinal);
if (start < 0)
{
throw new InvalidOperationException($"PEM {section} header not found");
}
start += header.Length;
int end = pem.IndexOf(footer, start, StringComparison.Ordinal);
if (end < 0)
{
throw new InvalidOperationException($"PEM {section} footer not found");
}
// Use Substring instead of range syntax for .NET Framework 4.8 compatibility
string base64 = pem.Substring(start, end - start).Replace("\r", "").Replace("\n", "");
return Convert.FromBase64String(base64);
}
}
}