mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-24 19:13:02 +08:00
438 lines
16 KiB
C#
438 lines
16 KiB
C#
/* ========================================================================
|
|
* Copyright (c) 2005-2026 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.Buffers;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace Opc.Ua.Bindings.Pcap.Audit
|
|
{
|
|
/// <summary>
|
|
/// Verification result for a tamper-evident Pcap audit ledger.
|
|
/// </summary>
|
|
public sealed record AuditChainVerification
|
|
{
|
|
/// <summary>
|
|
/// Constructs an audit chain verification result.
|
|
/// </summary>
|
|
public AuditChainVerification(int linesVerified, int firstCorruptLine, string? corruptionReason)
|
|
{
|
|
LinesVerified = linesVerified;
|
|
FirstCorruptLine = firstCorruptLine;
|
|
CorruptionReason = corruptionReason;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the number of lines verified before corruption was detected.
|
|
/// </summary>
|
|
public int LinesVerified { get; init; }
|
|
|
|
/// <summary>
|
|
/// Gets the first corrupt line number, or -1 when the chain is valid.
|
|
/// </summary>
|
|
public int FirstCorruptLine { get; init; }
|
|
|
|
/// <summary>
|
|
/// Gets the corruption reason, or <c>null</c> when the chain is valid.
|
|
/// </summary>
|
|
public string? CorruptionReason { get; init; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Writes Pcap audit events to a JSON-lines ledger with per-line HMAC chaining.
|
|
/// </summary>
|
|
public sealed class HashChainedAuditFileSink : IPcapAuditSink, IAsyncDisposable
|
|
{
|
|
private const int HmacLength = 32;
|
|
|
|
private readonly FileStream m_stream;
|
|
private readonly byte[] m_hmacKey;
|
|
private readonly SemaphoreSlim m_gate = new(1, 1);
|
|
private byte[] m_previousHmac;
|
|
private bool m_disposed;
|
|
|
|
/// <summary>
|
|
/// Constructs a tamper-evident audit sink for the supplied JSON-lines ledger.
|
|
/// </summary>
|
|
public HashChainedAuditFileSink(
|
|
string filePath,
|
|
byte[] hmacKey,
|
|
ILogger<HashChainedAuditFileSink>? logger)
|
|
{
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(filePath);
|
|
ArgumentNullException.ThrowIfNull(hmacKey);
|
|
|
|
if (hmacKey.Length != HmacLength)
|
|
{
|
|
throw new ArgumentException("The audit HMAC key must be 32 bytes.", nameof(hmacKey));
|
|
}
|
|
|
|
string? directory = Path.GetDirectoryName(filePath);
|
|
if (!string.IsNullOrEmpty(directory))
|
|
{
|
|
Directory.CreateDirectory(directory);
|
|
}
|
|
|
|
m_hmacKey = (byte[])hmacKey.Clone();
|
|
m_previousHmac = LoadPreviousHmac(filePath, logger);
|
|
m_stream = new FileStream(
|
|
filePath,
|
|
FileMode.Append,
|
|
FileAccess.Write,
|
|
FileShare.Read,
|
|
bufferSize: 4096,
|
|
FileOptions.Asynchronous | FileOptions.WriteThrough);
|
|
|
|
if (!OperatingSystem.IsWindows())
|
|
{
|
|
File.SetUnixFileMode(filePath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead);
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public async ValueTask OnEventAsync(PcapAuditEvent auditEvent, CancellationToken cancellationToken)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(auditEvent);
|
|
ObjectDisposedException.ThrowIf(m_disposed, this);
|
|
|
|
await m_gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
|
try
|
|
{
|
|
ObjectDisposedException.ThrowIf(m_disposed, this);
|
|
|
|
byte[] eventBytes = SerializeEvent(auditEvent);
|
|
byte[] previousHmac = (byte[])m_previousHmac.Clone();
|
|
byte[] newHmac = ComputeHmac(m_hmacKey, previousHmac, eventBytes);
|
|
byte[] lineBytes = BuildLedgerLine(eventBytes, previousHmac, newHmac);
|
|
|
|
await m_stream.WriteAsync(lineBytes, cancellationToken).ConfigureAwait(false);
|
|
await m_stream.FlushAsync(cancellationToken).ConfigureAwait(false);
|
|
|
|
m_previousHmac = newHmac;
|
|
}
|
|
finally
|
|
{
|
|
m_gate.Release();
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
if (m_disposed)
|
|
{
|
|
return;
|
|
}
|
|
|
|
await m_gate.WaitAsync(CancellationToken.None).ConfigureAwait(false);
|
|
try
|
|
{
|
|
if (m_disposed)
|
|
{
|
|
return;
|
|
}
|
|
|
|
await m_stream.DisposeAsync().ConfigureAwait(false);
|
|
m_disposed = true;
|
|
}
|
|
finally
|
|
{
|
|
m_gate.Release();
|
|
m_gate.Dispose();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies every line in a tamper-evident audit ledger.
|
|
/// </summary>
|
|
public static AuditChainVerification VerifyChain(string filePath, byte[] hmacKey)
|
|
{
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(filePath);
|
|
ArgumentNullException.ThrowIfNull(hmacKey);
|
|
|
|
if (hmacKey.Length != HmacLength)
|
|
{
|
|
throw new ArgumentException("The audit HMAC key must be 32 bytes.", nameof(hmacKey));
|
|
}
|
|
|
|
int linesVerified = 0;
|
|
byte[]? expectedPreviousHmac = null;
|
|
|
|
using var reader = new StreamReader(filePath, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
|
|
for (int lineNumber = 1; ; lineNumber++)
|
|
{
|
|
string? line = reader.ReadLine();
|
|
if (line is null)
|
|
{
|
|
break;
|
|
}
|
|
|
|
if (line.Length == 0)
|
|
{
|
|
return Corrupt(linesVerified, lineNumber, "Audit ledger line is empty.");
|
|
}
|
|
|
|
if (!TryReadLedgerLine(line, out byte[] eventBytes, out byte[] previousHmac, out byte[] hmac,
|
|
out string? error))
|
|
{
|
|
return Corrupt(linesVerified, lineNumber, error);
|
|
}
|
|
|
|
if (expectedPreviousHmac is not null && !CryptographicOperations.FixedTimeEquals(
|
|
previousHmac,
|
|
expectedPreviousHmac))
|
|
{
|
|
return Corrupt(linesVerified, lineNumber, "Previous HMAC does not match the prior line HMAC.");
|
|
}
|
|
|
|
byte[] computedHmac = ComputeHmac(hmacKey, previousHmac, eventBytes);
|
|
if (!CryptographicOperations.FixedTimeEquals(hmac, computedHmac))
|
|
{
|
|
return Corrupt(linesVerified, lineNumber, "Line HMAC does not match the event payload.");
|
|
}
|
|
|
|
linesVerified++;
|
|
expectedPreviousHmac = hmac;
|
|
}
|
|
|
|
return new AuditChainVerification(linesVerified, -1, null);
|
|
}
|
|
|
|
private static AuditChainVerification Corrupt(int linesVerified, int lineNumber, string? reason)
|
|
{
|
|
return new AuditChainVerification(linesVerified, lineNumber, reason ?? "Audit ledger line is corrupt.");
|
|
}
|
|
|
|
private static byte[] LoadPreviousHmac(string filePath, ILogger<HashChainedAuditFileSink>? logger)
|
|
{
|
|
if (!File.Exists(filePath) || new FileInfo(filePath).Length == 0)
|
|
{
|
|
return RandomNumberGenerator.GetBytes(HmacLength);
|
|
}
|
|
|
|
string? lastLine = null;
|
|
using (var reader = new StreamReader(filePath, Encoding.UTF8, detectEncodingFromByteOrderMarks: true))
|
|
{
|
|
while (reader.ReadLine() is { } line)
|
|
{
|
|
if (line.Length > 0)
|
|
{
|
|
lastLine = line;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (lastLine is null)
|
|
{
|
|
return RandomNumberGenerator.GetBytes(HmacLength);
|
|
}
|
|
|
|
if (TryReadLedgerLine(lastLine, out _, out _, out byte[] hmac, out string? error))
|
|
{
|
|
logger?.LogDebug(
|
|
"Continuing tamper-evident Pcap audit chain from existing ledger {FilePath}.",
|
|
filePath);
|
|
return hmac;
|
|
}
|
|
|
|
throw new InvalidDataException("Existing audit ledger has an unreadable final HMAC: " + error);
|
|
}
|
|
|
|
private static byte[] SerializeEvent(PcapAuditEvent auditEvent)
|
|
{
|
|
var buffer = new ArrayBufferWriter<byte>();
|
|
using (var writer = new Utf8JsonWriter(buffer))
|
|
{
|
|
writer.WriteStartObject();
|
|
writer.WriteString("kind", auditEvent.Kind.ToString());
|
|
writer.WriteString("timestamp", auditEvent.Timestamp);
|
|
WriteNullableString(writer, "sessionId", auditEvent.SessionId);
|
|
WriteNullableString(writer, "resourcePath", auditEvent.ResourcePath);
|
|
WriteNullableString(writer, "remoteEndpoint", auditEvent.RemoteEndpoint);
|
|
WriteProperties(writer, auditEvent.Properties);
|
|
writer.WriteEndObject();
|
|
}
|
|
|
|
return buffer.WrittenSpan.ToArray();
|
|
}
|
|
|
|
private static void WriteNullableString(Utf8JsonWriter writer, string propertyName, string? value)
|
|
{
|
|
if (value is null)
|
|
{
|
|
writer.WriteNull(propertyName);
|
|
return;
|
|
}
|
|
|
|
writer.WriteString(propertyName, value);
|
|
}
|
|
|
|
private static void WriteProperties(Utf8JsonWriter writer, IReadOnlyDictionary<string, string>? properties)
|
|
{
|
|
writer.WritePropertyName("properties");
|
|
if (properties is null)
|
|
{
|
|
writer.WriteNullValue();
|
|
return;
|
|
}
|
|
|
|
writer.WriteStartObject();
|
|
foreach (KeyValuePair<string, string> property in properties)
|
|
{
|
|
writer.WriteString(property.Key, property.Value);
|
|
}
|
|
|
|
writer.WriteEndObject();
|
|
}
|
|
|
|
private static byte[] BuildLedgerLine(byte[] eventBytes, byte[] previousHmac, byte[] hmac)
|
|
{
|
|
var buffer = new ArrayBufferWriter<byte>();
|
|
using (var writer = new Utf8JsonWriter(buffer))
|
|
{
|
|
writer.WriteStartObject();
|
|
writer.WritePropertyName("event");
|
|
using (JsonDocument document = JsonDocument.Parse(eventBytes))
|
|
{
|
|
document.RootElement.WriteTo(writer);
|
|
}
|
|
|
|
writer.WriteString("hmac", Convert.ToBase64String(hmac));
|
|
writer.WriteString("prev", Convert.ToBase64String(previousHmac));
|
|
writer.WriteEndObject();
|
|
}
|
|
|
|
byte[] lineBytes = new byte[buffer.WrittenCount + 1];
|
|
buffer.WrittenSpan.CopyTo(lineBytes);
|
|
lineBytes[^1] = (byte)'\n';
|
|
return lineBytes;
|
|
}
|
|
|
|
private static byte[] ComputeHmac(byte[] hmacKey, byte[] previousHmac, byte[] eventBytes)
|
|
{
|
|
using var hmac = new HMACSHA256(hmacKey);
|
|
hmac.TransformBlock(previousHmac, 0, previousHmac.Length, outputBuffer: null, outputOffset: 0);
|
|
hmac.TransformFinalBlock(eventBytes, 0, eventBytes.Length);
|
|
return hmac.Hash ?? throw new CryptographicException("HMAC-SHA256 did not produce a hash.");
|
|
}
|
|
|
|
private static bool TryReadLedgerLine(
|
|
string line,
|
|
out byte[] eventBytes,
|
|
out byte[] previousHmac,
|
|
out byte[] hmac,
|
|
out string? error)
|
|
{
|
|
eventBytes = [];
|
|
previousHmac = [];
|
|
hmac = [];
|
|
error = null;
|
|
|
|
try
|
|
{
|
|
using JsonDocument document = JsonDocument.Parse(line);
|
|
JsonElement root = document.RootElement;
|
|
if (root.ValueKind != JsonValueKind.Object)
|
|
{
|
|
error = "Audit ledger line is not a JSON object.";
|
|
return false;
|
|
}
|
|
|
|
if (!root.TryGetProperty("event", out JsonElement eventElement))
|
|
{
|
|
error = "Audit ledger line is missing the event property.";
|
|
return false;
|
|
}
|
|
|
|
if (!TryReadBase64Hmac(root, "prev", out previousHmac, out error) ||
|
|
!TryReadBase64Hmac(root, "hmac", out hmac, out error))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
eventBytes = Encoding.UTF8.GetBytes(eventElement.GetRawText());
|
|
return true;
|
|
}
|
|
catch (JsonException ex)
|
|
{
|
|
error = "Audit ledger line is not valid JSON: " + ex.Message;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private static bool TryReadBase64Hmac(
|
|
JsonElement root,
|
|
string propertyName,
|
|
out byte[] value,
|
|
out string? error)
|
|
{
|
|
value = [];
|
|
if (!root.TryGetProperty(propertyName, out JsonElement property) ||
|
|
property.ValueKind != JsonValueKind.String)
|
|
{
|
|
error = "Audit ledger line is missing the " + propertyName + " HMAC property.";
|
|
return false;
|
|
}
|
|
|
|
string? encodedValue = property.GetString();
|
|
if (encodedValue is null)
|
|
{
|
|
error = "Audit ledger line has a null " + propertyName + " HMAC.";
|
|
return false;
|
|
}
|
|
|
|
try
|
|
{
|
|
value = Convert.FromBase64String(encodedValue);
|
|
}
|
|
catch (FormatException ex)
|
|
{
|
|
error = "Audit ledger line has an invalid " + propertyName + " HMAC: " + ex.Message;
|
|
return false;
|
|
}
|
|
|
|
if (value.Length != HmacLength)
|
|
{
|
|
error = "Audit ledger line has a " + propertyName + " HMAC with the wrong length.";
|
|
return false;
|
|
}
|
|
|
|
error = null;
|
|
return true;
|
|
}
|
|
}
|
|
}
|