/* ======================================================================== * 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.Binary; using System.IO; using System.Security.Cryptography; using System.Threading; using System.Threading.Tasks; namespace Opc.Ua.Bindings.Pcap.KeyLog { /// /// Encrypts newline-delimited key-log payloads as AES-256-GCM records. /// internal sealed class EncryptedKeyLogStream : Stream { /// /// Constructs an AES-256-GCM wrapper around /// keyed by . /// public EncryptedKeyLogStream(Stream inner, byte[] sessionKey, bool leaveOpen) { ArgumentNullException.ThrowIfNull(inner); ArgumentNullException.ThrowIfNull(sessionKey); if (sessionKey.Length != SessionKeyManager.KeySizeInBytes) { throw new ArgumentException("The session key must be 32 bytes.", nameof(sessionKey)); } m_inner = inner; m_aesGcm = new AesGcm(sessionKey, TagSizeInBytes); m_leaveOpen = leaveOpen; } /// public override bool CanRead => m_inner.CanRead; /// public override bool CanSeek => false; /// public override bool CanWrite => m_inner.CanWrite; /// public override long Length => throw new NotSupportedException(); /// public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } /// public override void Flush() { FlushPendingWrite(); m_inner.Flush(); } /// public override Task FlushAsync(CancellationToken cancellationToken) { FlushPendingWrite(); return m_inner.FlushAsync(cancellationToken); } /// public override int Read(byte[] buffer, int offset, int count) { ValidateBuffer(buffer, offset, count); return Read(buffer.AsSpan(offset, count)); } /// public override int Read(Span buffer) { if (buffer.Length == 0) { return 0; } if (!EnsurePlaintextRecord()) { return 0; } return CopyPlaintext(buffer); } /// public override async ValueTask ReadAsync( Memory buffer, CancellationToken cancellationToken = default) { if (buffer.Length == 0) { return 0; } if (!await EnsurePlaintextRecordAsync(cancellationToken).ConfigureAwait(false)) { return 0; } return CopyPlaintext(buffer.Span); } /// public override Task ReadAsync( byte[] buffer, int offset, int count, CancellationToken cancellationToken) { ValidateBuffer(buffer, offset, count); return ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); } /// public override long Seek(long offset, SeekOrigin origin) { throw new NotSupportedException(); } /// public override void SetLength(long value) { throw new NotSupportedException(); } /// public override void Write(byte[] buffer, int offset, int count) { ValidateBuffer(buffer, offset, count); Write(buffer.AsSpan(offset, count)); } /// public override void Write(ReadOnlySpan buffer) { while (!buffer.IsEmpty) { int newlineIndex = buffer.IndexOf((byte)'\n'); int segmentLength = newlineIndex < 0 ? buffer.Length : newlineIndex + 1; AppendPendingWrite(buffer[..segmentLength]); buffer = buffer[segmentLength..]; if (newlineIndex >= 0) { FlushPendingWrite(); } } } /// public override ValueTask WriteAsync( ReadOnlyMemory buffer, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); Write(buffer.Span); return ValueTask.CompletedTask; } /// public override Task WriteAsync( byte[] buffer, int offset, int count, CancellationToken cancellationToken) { ValidateBuffer(buffer, offset, count); return WriteAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); } /// protected override void Dispose(bool disposing) { if (disposing) { FlushPendingWrite(); m_aesGcm.Dispose(); if (!m_leaveOpen) { m_inner.Dispose(); } } base.Dispose(disposing); } /// public override async ValueTask DisposeAsync() { FlushPendingWrite(); m_aesGcm.Dispose(); if (!m_leaveOpen) { await m_inner.DisposeAsync().ConfigureAwait(false); } await base.DisposeAsync().ConfigureAwait(false); } private static void ValidateBuffer(byte[] buffer, int offset, int count) { ArgumentNullException.ThrowIfNull(buffer); ArgumentOutOfRangeException.ThrowIfNegative(offset); ArgumentOutOfRangeException.ThrowIfNegative(count); if (buffer.Length - offset < count) { throw new ArgumentException("The buffer range is invalid.", nameof(count)); } } private void AppendPendingWrite(ReadOnlySpan bytes) { EnsureWriteBuffer(bytes.Length); bytes.CopyTo(m_writeBuffer.AsSpan(m_writeCount)); m_writeCount += bytes.Length; } private void EnsureWriteBuffer(int additionalBytes) { int requiredLength = m_writeCount + additionalBytes; if (m_writeBuffer.Length >= requiredLength) { return; } int newLength = Math.Max(requiredLength, Math.Max(DefaultBufferSize, m_writeBuffer.Length * 2)); Array.Resize(ref m_writeBuffer, newLength); } private void FlushPendingWrite() { if (m_writeCount == 0) { return; } WriteEncryptedRecord(m_writeBuffer.AsSpan(0, m_writeCount)); m_writeCount = 0; } private void WriteEncryptedRecord(ReadOnlySpan plaintext) { int recordLength = NonceSizeInBytes + plaintext.Length + TagSizeInBytes; Span lengthBytes = stackalloc byte[sizeof(int)]; BinaryPrimitives.WriteInt32LittleEndian(lengthBytes, recordLength); m_inner.Write(lengthBytes); byte[] record = new byte[recordLength]; Span nonce = record.AsSpan(0, NonceSizeInBytes); RandomNumberGenerator.Fill(nonce); Span ciphertext = record.AsSpan(NonceSizeInBytes, plaintext.Length); Span tag = record.AsSpan(NonceSizeInBytes + plaintext.Length, TagSizeInBytes); m_aesGcm.Encrypt(nonce, plaintext, ciphertext, tag); m_inner.Write(record); } private bool EnsurePlaintextRecord() { if (m_readBufferOffset < m_readBuffer.Length) { return true; } Span lengthBytes = stackalloc byte[sizeof(int)]; int read = ReadAtMost(lengthBytes); if (read == 0) { return false; } if (read != lengthBytes.Length) { throw new InvalidDataException("Encrypted key-log record length is incomplete."); } int recordLength = BinaryPrimitives.ReadInt32LittleEndian(lengthBytes); m_readBuffer = ReadEncryptedRecord(recordLength); m_readBufferOffset = 0; return true; } private async ValueTask EnsurePlaintextRecordAsync(CancellationToken cancellationToken) { if (m_readBufferOffset < m_readBuffer.Length) { return true; } byte[] lengthBytes = new byte[sizeof(int)]; int read = await ReadAtMostAsync(lengthBytes, cancellationToken).ConfigureAwait(false); if (read == 0) { return false; } if (read != lengthBytes.Length) { throw new InvalidDataException("Encrypted key-log record length is incomplete."); } int recordLength = BinaryPrimitives.ReadInt32LittleEndian(lengthBytes); m_readBuffer = await ReadEncryptedRecordAsync(recordLength, cancellationToken).ConfigureAwait(false); m_readBufferOffset = 0; return true; } private byte[] ReadEncryptedRecord(int recordLength) { ValidateRecordLength(recordLength); byte[] record = new byte[recordLength]; ReadExactly(record); return DecryptRecord(record); } private async ValueTask ReadEncryptedRecordAsync(int recordLength, CancellationToken cancellationToken) { ValidateRecordLength(recordLength); byte[] record = new byte[recordLength]; await ReadExactlyAsync(record, cancellationToken).ConfigureAwait(false); return DecryptRecord(record); } private static void ValidateRecordLength(int recordLength) { if (recordLength < NonceSizeInBytes + TagSizeInBytes) { throw new InvalidDataException("Encrypted key-log record length is invalid."); } } private byte[] DecryptRecord(byte[] record) { ReadOnlySpan nonce = record.AsSpan(0, NonceSizeInBytes); int ciphertextLength = record.Length - NonceSizeInBytes - TagSizeInBytes; ReadOnlySpan ciphertext = record.AsSpan(NonceSizeInBytes, ciphertextLength); ReadOnlySpan tag = record.AsSpan(NonceSizeInBytes + ciphertextLength, TagSizeInBytes); byte[] plaintext = new byte[ciphertextLength]; m_aesGcm.Decrypt(nonce, ciphertext, tag, plaintext); return plaintext; } private int CopyPlaintext(Span destination) { int bytesToCopy = Math.Min(destination.Length, m_readBuffer.Length - m_readBufferOffset); m_readBuffer.AsSpan(m_readBufferOffset, bytesToCopy).CopyTo(destination); m_readBufferOffset += bytesToCopy; return bytesToCopy; } private int ReadAtMost(Span buffer) { int totalRead = 0; while (totalRead < buffer.Length) { int read = m_inner.Read(buffer[totalRead..]); if (read == 0) { break; } totalRead += read; } return totalRead; } private async ValueTask ReadAtMostAsync(Memory buffer, CancellationToken cancellationToken) { int totalRead = 0; while (totalRead < buffer.Length) { int read = await m_inner.ReadAsync(buffer[totalRead..], cancellationToken).ConfigureAwait(false); if (read == 0) { break; } totalRead += read; } return totalRead; } private void ReadExactly(byte[] buffer) { if (ReadAtMost(buffer) != buffer.Length) { throw new EndOfStreamException("Encrypted key-log record ended unexpectedly."); } } private async ValueTask ReadExactlyAsync(byte[] buffer, CancellationToken cancellationToken) { if (await ReadAtMostAsync(buffer, cancellationToken).ConfigureAwait(false) != buffer.Length) { throw new EndOfStreamException("Encrypted key-log record ended unexpectedly."); } } private const int DefaultBufferSize = 1024; private const int NonceSizeInBytes = 12; private const int TagSizeInBytes = 16; private readonly AesGcm m_aesGcm; private readonly Stream m_inner; private readonly bool m_leaveOpen; private byte[] m_readBuffer = Array.Empty(); private int m_readBufferOffset; private byte[] m_writeBuffer = Array.Empty(); private int m_writeCount; } }