diff --git a/Yubico.YubiKey/src/Resources/ExceptionMessages.Designer.cs b/Yubico.YubiKey/src/Resources/ExceptionMessages.Designer.cs index 69a46559b..d48a16173 100644 --- a/Yubico.YubiKey/src/Resources/ExceptionMessages.Designer.cs +++ b/Yubico.YubiKey/src/Resources/ExceptionMessages.Designer.cs @@ -2443,5 +2443,140 @@ internal static string YubiKeyOperationFailed { return ResourceManager.GetString("YubiKeyOperationFailed", resourceCulture); } } + + /// + /// Looks up a localized string similar to Error generating key pair: {0}. + /// + internal static string GenerateKeyPairFailed { + get { + return ResourceManager.GetString("GenerateKeyPairFailed", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Certificate data too short to determine compression format.. + /// + internal static string CertificateDataTooShortToDetectFormat { + get { + return ResourceManager.GetString("CertificateDataTooShortToDetectFormat", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Could not detect compression format.. + /// + internal static string CouldNotDetectCompressionFormat { + get { + return ResourceManager.GetString("CouldNotDetectCompressionFormat", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Decompressed data length {0} does not match expected length {1} from GIDS header.. + /// + internal static string DecompressedLengthMismatch { + get { + return ResourceManager.GetString("DecompressedLengthMismatch", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The stream does not support writing.. + /// + internal static string StreamDoesNotSupportWriting { + get { + return ResourceManager.GetString("StreamDoesNotSupportWriting", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The stream does not support reading.. + /// + internal static string StreamDoesNotSupportReading { + get { + return ResourceManager.GetString("StreamDoesNotSupportReading", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Invalid CompressionMode value.. + /// + internal static string InvalidCompressionModeValue { + get { + return ResourceManager.GetString("InvalidCompressionModeValue", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Reading is not supported on compression streams.. + /// + internal static string ReadingNotSupportedOnCompressionStreams { + get { + return ResourceManager.GetString("ReadingNotSupportedOnCompressionStreams", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Writing is not supported on decompression streams.. + /// + internal static string WritingNotSupportedOnDecompressionStreams { + get { + return ResourceManager.GetString("WritingNotSupportedOnDecompressionStreams", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to CopyTo is not supported on compression streams.. + /// + internal static string CopyToNotSupportedOnCompressionStreams { + get { + return ResourceManager.GetString("CopyToNotSupportedOnCompressionStreams", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to CopyToAsync is not supported on compression streams.. + /// + internal static string CopyToAsyncNotSupportedOnCompressionStreams { + get { + return ResourceManager.GetString("CopyToAsyncNotSupportedOnCompressionStreams", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Unexpected end of stream while reading zlib header.. + /// + internal static string UnexpectedEndOfZlibHeader { + get { + return ResourceManager.GetString("UnexpectedEndOfZlibHeader", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Invalid zlib header checksum.. + /// + internal static string InvalidZlibHeaderChecksum { + get { + return ResourceManager.GetString("InvalidZlibHeaderChecksum", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Unsupported zlib compression method: {0}. Only deflate (8) is supported.. + /// + internal static string UnsupportedZlibCompressionMethod { + get { + return ResourceManager.GetString("UnsupportedZlibCompressionMethod", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Zlib streams with a preset dictionary are not supported.. + /// + internal static string ZlibPresetDictionaryNotSupported { + get { + return ResourceManager.GetString("ZlibPresetDictionaryNotSupported", resourceCulture); + } + } } } diff --git a/Yubico.YubiKey/src/Resources/ExceptionMessages.resx b/Yubico.YubiKey/src/Resources/ExceptionMessages.resx index fcee6f050..3b6911e76 100644 --- a/Yubico.YubiKey/src/Resources/ExceptionMessages.resx +++ b/Yubico.YubiKey/src/Resources/ExceptionMessages.resx @@ -916,4 +916,49 @@ Key agreement receipts do not match + + Error generating key pair: {0} + + + Certificate data too short to determine compression format. + + + Could not detect compression format. + + + Decompressed data length {0} does not match expected length {1} from GIDS header. + + + The stream does not support writing. + + + The stream does not support reading. + + + Invalid CompressionMode value. + + + Reading is not supported on compression streams. + + + Writing is not supported on decompression streams. + + + CopyTo is not supported on compression streams. + + + CopyToAsync is not supported on compression streams. + + + Unexpected end of stream while reading zlib header. + + + Invalid zlib header checksum. + + + Unsupported zlib compression method: {0}. Only deflate (8) is supported. + + + Zlib streams with a preset dictionary are not supported. + \ No newline at end of file diff --git a/Yubico.YubiKey/src/Yubico/YubiKey/Cryptography/ZLibStream.cs b/Yubico.YubiKey/src/Yubico/YubiKey/Cryptography/ZLibStream.cs new file mode 100644 index 000000000..bda4904be --- /dev/null +++ b/Yubico.YubiKey/src/Yubico/YubiKey/Cryptography/ZLibStream.cs @@ -0,0 +1,598 @@ +// Copyright 2025 Yubico AB +// +// Licensed under the Apache License, Version 2.0 (the "License"). +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// This implementation is based on RFC 1950 (ZLIB Compressed Data Format Specification). +// It handles the zlib framing (header + Adler-32 trailer) around raw deflate data, +// delegating the actual inflate/deflate work to the standard System.IO.Compression.DeflateStream. + +using System; +using System.IO; +using System.IO.Compression; +using System.Threading; +using System.Threading.Tasks; + +namespace Yubico.YubiKey.Cryptography +{ + /// + /// Provides methods and properties used to compress and decompress streams by + /// using the zlib data format specification (RFC 1950). + /// + /// + /// + /// The zlib format wraps raw DEFLATE compressed data with a 2-byte header + /// (CMF and FLG) and a 4-byte Adler-32 checksum trailer. This class handles + /// the framing and delegates the actual compression/decompression to + /// . + /// + /// + /// During compression, an Adler-32 checksum of all written bytes is + /// computed and appended as a 4-byte big-endian trailer when the stream is + /// disposed, producing a fully RFC 1950-compliant zlib stream. + /// + /// + /// During decompression, the 2-byte zlib header is validated (checksum, + /// compression method, and FDICT flag), but the 4-byte Adler-32 trailer is + /// not verified. Corruption that is not caught by the underlying DEFLATE + /// decoder will go undetected. + /// + /// + /// This implementation targets .NET Standard 2.0 / 2.1 / .NET Framework 4.7.2 + /// where System.IO.Compression.ZLibStream is not available. + /// + /// + internal sealed class ZLibStream : Stream + { + /// + /// The default zlib CMF byte: deflate method (CM=8), window size 2^15 (CINFO=7). + /// + private const byte DefaultCmf = 0x78; + + /// + /// The FLG byte for default compression level. + /// Chosen so that (DefaultCmf * 256 + DefaultFlg) % 31 == 0. + /// + private const byte DefaultFlg = 0x9C; + + private readonly CompressionMode _mode; + private readonly bool _leaveOpen; + private DeflateStream? _deflateStream; + private bool _headerProcessed; + private bool _disposed; + + // For compression: tracks written data for Adler-32 computation + private uint _adlerA = 1; + private uint _adlerB; + + /// + /// Initializes a new instance of the class by using the + /// specified stream and compression mode. + /// + /// The stream to which compressed data is written or from + /// which data to decompress is read. + /// One of the enumeration values that indicates whether to + /// compress data to the stream or decompress data from the stream. + public ZLibStream(Stream stream, CompressionMode mode) + : this(stream, mode, leaveOpen: false) + { + } + + /// + /// Initializes a new instance of the class by using the + /// specified stream, compression mode, and whether to leave the stream open. + /// + /// The stream to which compressed data is written or from + /// which data to decompress is read. + /// One of the enumeration values that indicates whether to + /// compress data to the stream or decompress data from the stream. + /// to leave the stream object open + /// after disposing the object; otherwise, + /// . + /// is + /// . + /// is + /// and the stream does not support + /// reading, or is + /// and the stream does not support writing. + public ZLibStream(Stream stream, CompressionMode mode, bool leaveOpen) + { + BaseStream = stream ?? throw new ArgumentNullException(nameof(stream)); + _mode = mode; + _leaveOpen = leaveOpen; + + if (mode == CompressionMode.Compress) + { + if (!stream.CanWrite) + { + throw new ArgumentException(ExceptionMessages.StreamDoesNotSupportWriting, nameof(stream)); + } + } + else if (mode == CompressionMode.Decompress) + { + if (!stream.CanRead) + { + throw new ArgumentException(ExceptionMessages.StreamDoesNotSupportReading, nameof(stream)); + } + } + else + { + throw new ArgumentException(ExceptionMessages.InvalidCompressionModeValue, nameof(mode)); + } + } + + /// + /// Initializes a new instance of the class by using the + /// specified stream and compression level. + /// + /// The stream to which compressed data is written. + /// One of the enumeration values that indicates + /// whether to emphasize speed or compression efficiency when compressing data. + public ZLibStream(Stream stream, CompressionLevel compressionLevel) + : this(stream, compressionLevel, leaveOpen: false) + { + } + + /// + /// Initializes a new instance of the class by using the + /// specified stream, compression level, and whether to leave the stream open. + /// + /// The stream to which compressed data is written. + /// One of the enumeration values that indicates + /// whether to emphasize speed or compression efficiency when compressing data. + /// to leave the stream object open + /// after disposing the object; otherwise, + /// . + public ZLibStream(Stream stream, CompressionLevel compressionLevel, bool leaveOpen) + { + BaseStream = stream ?? throw new ArgumentNullException(nameof(stream)); + + if (!stream.CanWrite) + { + throw new ArgumentException(ExceptionMessages.StreamDoesNotSupportWriting, nameof(stream)); + } + + _mode = CompressionMode.Compress; + _leaveOpen = leaveOpen; + + // Write the zlib header immediately + WriteZLibHeader(compressionLevel); + + _deflateStream = new DeflateStream(stream, compressionLevel, leaveOpen: true); + _headerProcessed = true; + } + + /// + public override bool CanRead => !_disposed && _mode == CompressionMode.Decompress; + + /// + public override bool CanWrite => !_disposed && _mode == CompressionMode.Compress; + + /// + public override bool CanSeek => false; + + /// + public override long Length => throw new NotSupportedException(); + + /// + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + /// + /// Gets a reference to the underlying stream. + /// + public Stream BaseStream { get; } + + /// + public override int Read(byte[] buffer, int offset, int count) + { + ThrowIfDisposed(); + + if (_mode != CompressionMode.Decompress) + { + throw new InvalidOperationException(ExceptionMessages.ReadingNotSupportedOnCompressionStreams); + } + + EnsureDecompressionInitialized(); + + return _deflateStream!.Read(buffer, offset, count); + } + + /// + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + ThrowIfDisposed(); + + if (_mode != CompressionMode.Decompress) + { + throw new InvalidOperationException(ExceptionMessages.ReadingNotSupportedOnCompressionStreams); + } + + EnsureDecompressionInitialized(); + + return _deflateStream!.ReadAsync(buffer, offset, count, cancellationToken); + } + + /// + public override int ReadByte() + { + ThrowIfDisposed(); + + if (_mode != CompressionMode.Decompress) + { + throw new InvalidOperationException(ExceptionMessages.ReadingNotSupportedOnCompressionStreams); + } + + EnsureDecompressionInitialized(); + + return _deflateStream!.ReadByte(); + } + + /// + public override void Write(byte[] buffer, int offset, int count) + { + ThrowIfDisposed(); + + if (_mode != CompressionMode.Compress) + { + throw new InvalidOperationException(ExceptionMessages.WritingNotSupportedOnDecompressionStreams); + } + + EnsureCompressionInitialized(); + + // Track uncompressed data for Adler-32 + UpdateAdler32(buffer, offset, count); + + _deflateStream!.Write(buffer, offset, count); + } + + /// + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + ThrowIfDisposed(); + + if (_mode != CompressionMode.Compress) + { + throw new InvalidOperationException(ExceptionMessages.WritingNotSupportedOnDecompressionStreams); + } + + EnsureCompressionInitialized(); + + // Track uncompressed data for Adler-32 + UpdateAdler32(buffer, offset, count); + + return _deflateStream!.WriteAsync(buffer, offset, count, cancellationToken); + } + +#if NETSTANDARD2_1_OR_GREATER + /// + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + ThrowIfDisposed(); + + if (_mode != CompressionMode.Decompress) + { + throw new InvalidOperationException(ExceptionMessages.ReadingNotSupportedOnCompressionStreams); + } + + EnsureDecompressionInitialized(); + + return _deflateStream!.ReadAsync(buffer, cancellationToken); + } + + /// + public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + ThrowIfDisposed(); + + if (_mode != CompressionMode.Compress) + { + throw new InvalidOperationException(ExceptionMessages.WritingNotSupportedOnDecompressionStreams); + } + + EnsureCompressionInitialized(); + + // Track uncompressed data for Adler-32 + if (!buffer.IsEmpty) + { + byte[] temp = buffer.ToArray(); + UpdateAdler32(temp, 0, temp.Length); + } + + return _deflateStream!.WriteAsync(buffer, cancellationToken); + } +#endif + + /// + public override void Flush() + { + ThrowIfDisposed(); + _deflateStream?.Flush(); + } + + /// + public override Task FlushAsync(CancellationToken cancellationToken) + { + ThrowIfDisposed(); + + if (_deflateStream != null) + { + return _deflateStream.FlushAsync(cancellationToken); + } + + return Task.CompletedTask; + } + + /// + public override long Seek(long offset, SeekOrigin origin) => + throw new NotSupportedException(); + + /// + public override void SetLength(long value) => + throw new NotSupportedException(); + +#if NETSTANDARD2_1_OR_GREATER + /// + public override void CopyTo(Stream destination, int bufferSize) + { + ThrowIfDisposed(); + + if (_mode != CompressionMode.Decompress) + { + throw new InvalidOperationException(ExceptionMessages.CopyToNotSupportedOnCompressionStreams); + } + + EnsureDecompressionInitialized(); + + _deflateStream!.CopyTo(destination, bufferSize); + } +#endif + + /// + public override Task CopyToAsync(Stream destination, int bufferSize, CancellationToken cancellationToken) + { + ThrowIfDisposed(); + + if (_mode != CompressionMode.Decompress) + { + throw new InvalidOperationException(ExceptionMessages.CopyToAsyncNotSupportedOnCompressionStreams); + } + + EnsureDecompressionInitialized(); + + return _deflateStream!.CopyToAsync(destination, bufferSize, cancellationToken); + } + + /// + protected override void Dispose(bool disposing) + { + if (!_disposed) + { + if (disposing) + { + if (_mode == CompressionMode.Compress && _deflateStream != null) + { + // Flush and close the deflate stream to finalize compressed data + _deflateStream.Dispose(); + _deflateStream = null; + + // Write the Adler-32 checksum trailer (big-endian) + WriteAdler32Trailer(); + } + else + { + _deflateStream?.Dispose(); + _deflateStream = null; + } + + if (!_leaveOpen) + { + BaseStream.Dispose(); + } + } + + _disposed = true; + } + + base.Dispose(disposing); + } + + /// + /// Reads and validates the 2-byte zlib header (RFC 1950 section 2.2). + /// After validation, creates the internal + /// positioned at the start of the raw deflate data. + /// + /// The zlib header is invalid. + private void ReadAndValidateZLibHeader() + { + int cmf = BaseStream.ReadByte(); + int flg = BaseStream.ReadByte(); + + if (cmf == -1 || flg == -1) + { + throw new InvalidDataException(ExceptionMessages.UnexpectedEndOfZlibHeader); + } + + // Validate the header checksum: (CMF * 256 + FLG) must be divisible by 31 + if (((cmf * 256) + flg) % 31 != 0) + { + throw new InvalidDataException(ExceptionMessages.InvalidZlibHeaderChecksum); + } + + // Extract compression method (lower 4 bits of CMF) + int compressionMethod = cmf & 0x0F; + if (compressionMethod != 8) + { + throw new InvalidDataException( + string.Format( + System.Globalization.CultureInfo.CurrentCulture, + ExceptionMessages.UnsupportedZlibCompressionMethod, + compressionMethod)); + } + + // Check FDICT flag (bit 5 of FLG) - preset dictionary not supported + bool hasPresetDictionary = (flg & 0x20) != 0; + if (hasPresetDictionary) + { + throw new InvalidDataException(ExceptionMessages.ZlibPresetDictionaryNotSupported); + } + } + + /// + /// Writes the 2-byte zlib header to the base stream. + /// + private void WriteZLibHeader(CompressionLevel compressionLevel) + { + byte cmf = DefaultCmf; + byte flg; + + // Choose FLEVEL based on compression level and ensure header checksum is valid + switch (compressionLevel) + { + case CompressionLevel.NoCompression: + // FLEVEL = 0 (compressor used fastest algorithm) + flg = ComputeFlg(cmf, 0); + break; + case CompressionLevel.Fastest: + // FLEVEL = 1 (compressor used fast algorithm) + flg = ComputeFlg(cmf, 1); + break; + default: + // FLEVEL = 2 (default) - covers Optimal and SmallestSize + flg = DefaultFlg; + break; + } + + BaseStream.WriteByte(cmf); + BaseStream.WriteByte(flg); + } + + /// + /// Computes the FLG byte given a CMF byte and desired FLEVEL (0-3). + /// Ensures that (CMF * 256 + FLG) % 31 == 0 per RFC 1950. + /// + private static byte ComputeFlg(byte cmf, int flevel) + { + // FLG layout: FLEVEL (2 bits) | FDICT (1 bit, 0) | FCHECK (5 bits) + int flgBase = (flevel & 0x03) << 6; + int remainder = ((cmf * 256) + flgBase) % 31; + int fcheck = (31 - remainder) % 31; + + return (byte)(flgBase | fcheck); + } + + /// + /// Writes the 4-byte Adler-32 checksum trailer in big-endian byte order. + /// + private void WriteAdler32Trailer() + { + uint checksum = (_adlerB << 16) | _adlerA; + + BaseStream.WriteByte((byte)(checksum >> 24)); + BaseStream.WriteByte((byte)(checksum >> 16)); + BaseStream.WriteByte((byte)(checksum >> 8)); + BaseStream.WriteByte((byte)checksum); + } + + /// + /// Updates the running Adler-32 checksum with the given data. + /// + /// + /// Adler-32 is defined in RFC 1950 section 9. It consists of two 16-bit + /// checksums A and B: A = 1 + sum of all bytes, B = sum of all A values, + /// both modulo 65521. + /// + private void UpdateAdler32(byte[] buffer, int offset, int count) + { + const uint modAdler = 65521; + + for (int i = offset; i < offset + count; i++) + { + _adlerA = (_adlerA + buffer[i]) % modAdler; + _adlerB = (_adlerB + _adlerA) % modAdler; + } + } + + /// + /// Ensures the zlib header has been read and the internal DeflateStream + /// is initialized for decompression. + /// + private void EnsureDecompressionInitialized() + { + if (!_headerProcessed) + { + ReadAndValidateZLibHeader(); + _deflateStream = new DeflateStream(BaseStream, CompressionMode.Decompress, leaveOpen: true); + _headerProcessed = true; + } + } + + /// + /// Ensures the zlib header has been written and the internal DeflateStream + /// is initialized for compression. + /// + private void EnsureCompressionInitialized() + { + if (!_headerProcessed) + { + // Default compression level header + BaseStream.WriteByte(DefaultCmf); + BaseStream.WriteByte(DefaultFlg); + _deflateStream = new DeflateStream(BaseStream, CompressionLevel.Optimal, leaveOpen: true); + _headerProcessed = true; + } + } + + private void ThrowIfDisposed() + { + if (_disposed) + { + throw new ObjectDisposedException(GetType().FullName); + } + } + + /// + /// Computes the Adler-32 checksum over an entire byte array. + /// + /// The data to compute the checksum for. + /// The 32-bit Adler-32 checksum value. + internal static uint ComputeAdler32(byte[] data) + { + return ComputeAdler32(data, 0, data.Length); + } + + /// + /// Computes the Adler-32 checksum over a segment of a byte array. + /// + /// The data to compute the checksum for. + /// The offset into the data to start from. + /// The number of bytes to include. + /// The 32-bit Adler-32 checksum value. + internal static uint ComputeAdler32(byte[] data, int offset, int count) + { + const uint modAdler = 65521; + uint a = 1; + uint b = 0; + + for (int i = offset; i < offset + count; i++) + { + a = (a + data[i]) % modAdler; + b = (b + a) % modAdler; + } + + return (b << 16) | a; + } + } +} diff --git a/Yubico.YubiKey/src/Yubico/YubiKey/Piv/PivSession.KeyPairs.cs b/Yubico.YubiKey/src/Yubico/YubiKey/Piv/PivSession.KeyPairs.cs index 1a7decc40..b06cddcda 100644 --- a/Yubico.YubiKey/src/Yubico/YubiKey/Piv/PivSession.KeyPairs.cs +++ b/Yubico.YubiKey/src/Yubico/YubiKey/Piv/PivSession.KeyPairs.cs @@ -176,7 +176,11 @@ public IPublicKey GenerateKeyPair( if (response.Status != ResponseStatus.Success) { - throw new InvalidOperationException("Error generating key pair: " + response); + throw new InvalidOperationException( + string.Format( + CultureInfo.CurrentCulture, + ExceptionMessages.GenerateKeyPairFailed, + response)); } return PivKeyDecoder.CreatePublicKey(response.Data, keyType); @@ -605,14 +609,16 @@ public X509Certificate2 GetCertificate(byte slotNumber) try { - return new X509Certificate2(Decompress(certBytesCopy)); + byte[] decompressedData = DecompressWithFormatDetection(certBytesCopy); + return new X509Certificate2(decompressedData); } - catch (Exception) + catch (Exception ex) { throw new InvalidOperationException( string.Format( CultureInfo.CurrentCulture, - ExceptionMessages.FailedDecompressingCertificate)); + ExceptionMessages.FailedDecompressingCertificate), + ex); } } @@ -661,14 +667,113 @@ static private byte[] Compress(byte[] data) return compressedStream.ToArray(); } - static private byte[] Decompress(byte[] data) + static private byte[] Decompress(byte[] data, int offset = 0) { - using var dataStream = new MemoryStream(data); - using var decompressor = new GZipStream(dataStream, CompressionMode.Decompress); - using var decompressedStream = new MemoryStream(); - decompressor.CopyTo(decompressedStream); - - return decompressedStream.ToArray(); + using (var dataStream = new MemoryStream(data, offset, data.Length - offset)) + { + using (var decompressor = new GZipStream(dataStream, CompressionMode.Decompress)) + { + using (var decompressedStream = new MemoryStream()) + { + decompressor.CopyTo(decompressedStream); + return decompressedStream.ToArray(); + } + } + } + } + + /// + /// Decompresses a certificate by detecting the compression format. + /// + /// + /// + /// Attempts to decompress using the following formats in order of detection: + /// + /// + /// GZip (magic bytes 0x1F, 0x8B) — as specified by the PIV standard for + /// compressed certificates. + /// GIDS (magic bytes 0x01, 0x00 + 2-byte LE uncompressed length) + /// followed by zlib (RFC 1950) compressed data, as used by the GIDS smartcard + /// standard. + /// + /// + /// If none of the above formats are detected, throws an exception. + /// + /// + static private byte[] DecompressWithFormatDetection(byte[] data) + { + if (data.Length < 2) + { + throw new InvalidOperationException(ExceptionMessages.CertificateDataTooShortToDetectFormat); + } + + // Check for GZip magic bytes (0x1F, 0x8B) + if (data[0] == 0x1F && data[1] == 0x8B) + { + return Decompress(data); + } + + // Check for GIDS header (0x01, 0x00) followed by 2-byte LE length and zlib payload + if (data[0] == 0x01 && data[1] == 0x00 && data.Length >= 6) + { + return DecompressGids(data); + } + + throw new InvalidOperationException(ExceptionMessages.CouldNotDetectCompressionFormat); + } + + /// + /// Decompresses GIDS-formatted data. + /// + /// + /// + /// The GIDS format uses a 4-byte header: + /// + /// + /// Bytes 0–1: Magic prefix (0x01, 0x00). + /// Bytes 2–3: Expected uncompressed data length in little-endian byte order. + /// + /// + /// After the 4-byte header, the payload is zlib (RFC 1950) compressed data. + /// The decompressed length is validated against the expected length from the header. + /// + /// + static private byte[] DecompressGids(byte[] data) + { + const int gidsHeaderLength = 4; + + int expectedLength = data[2] | (data[3] << 8); + byte[] decompressed = DecompressZlib(data, offset: gidsHeaderLength); + + if (decompressed.Length != expectedLength) + { + throw new InvalidOperationException( + string.Format( + CultureInfo.CurrentCulture, + ExceptionMessages.DecompressedLengthMismatch, + decompressed.Length, + expectedLength)); + } + + return decompressed; + } + + /// + /// Decompresses zlib (RFC 1950) data starting at the specified offset. + /// + static private byte[] DecompressZlib(byte[] data, int offset = 0) + { + using (var dataStream = new MemoryStream(data, offset, data.Length - offset)) + { + using (var decompressor = new ZLibStream(dataStream, CompressionMode.Decompress)) + { + using (var decompressedStream = new MemoryStream()) + { + decompressor.CopyTo(decompressedStream); + return decompressedStream.ToArray(); + } + } + } } } } diff --git a/Yubico.YubiKey/tests/unit/Yubico/YubiKey/Cryptography/ZLibStreamTests.cs b/Yubico.YubiKey/tests/unit/Yubico/YubiKey/Cryptography/ZLibStreamTests.cs new file mode 100644 index 000000000..e7e73d01e --- /dev/null +++ b/Yubico.YubiKey/tests/unit/Yubico/YubiKey/Cryptography/ZLibStreamTests.cs @@ -0,0 +1,574 @@ +// Copyright 2025 Yubico AB +// +// Licensed under the Apache License, Version 2.0 (the "License"). +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.IO; +using System.IO.Compression; +using System.Text; +using Xunit; + +namespace Yubico.YubiKey.Cryptography +{ + public class ZLibStreamTests + { + // "Hello, World!" compressed with zlib (RFC 1950). + private static readonly byte[] ZLibCompressedHelloWorld = + { + 0x78, 0x9C, 0xF3, 0x48, 0xCD, 0xC9, 0xC9, 0xD7, + 0x51, 0x08, 0xCF, 0x2F, 0xCA, 0x49, 0x51, 0x04, + 0x00, 0x20, 0x5E, 0x04, 0x8A + }; + private const string HelloWorldText = "Hello, World!"; + + [Fact] + public void Decompress_ValidZLibData_ReturnsOriginalData() + { + // Pure zlib (RFC 1950) data without any prefix. + string hex = "789c8b2c4dcaf4ce2c5148cb2f5270cc4b29cacf4c5128492d2e5148492c4904009f2e0aa4"; + + byte[] data = Convert.FromHexString(hex); + + using var compressedStream = new MemoryStream(data); + using var zlibStream = new ZLibStream(compressedStream, CompressionMode.Decompress); + using var resultStream = new MemoryStream(); + + zlibStream.CopyTo(resultStream); + string result = Encoding.UTF8.GetString(resultStream.ToArray()); + + Assert.Equal("YubiKit for Android test data", result); + } + + [Fact] + public void Decompress_GidsFormat_StripsHeaderAndDecompresses() + { + // GIDS format: 4-byte header (01 00 = magic, 1D 00 = LE uncompressed length 29) + // followed by standard zlib (RFC 1950) data. + string hex = "01001d00789c8b2c4dcaf4ce2c5148cb2f5270cc4b29cacf4c5128492d2e5148492c4904009f2e0aa4"; + + byte[] data = Convert.FromHexString(hex); + + // Strip 4-byte GIDS header, then decompress the zlib payload + const int gidsHeaderLength = 4; + using var compressedStream = new MemoryStream(data, gidsHeaderLength, data.Length - gidsHeaderLength); + using var zlibStream = new ZLibStream(compressedStream, CompressionMode.Decompress); + using var resultStream = new MemoryStream(); + + zlibStream.CopyTo(resultStream); + string result = Encoding.UTF8.GetString(resultStream.ToArray()); + + Assert.Equal("YubiKit for Android test data", result); + } + + [Fact] + public void Decompress_ReadByteArray_ReturnsOriginalData() + { + using var compressedStream = new MemoryStream(ZLibCompressedHelloWorld); + using var zlibStream = new ZLibStream(compressedStream, CompressionMode.Decompress); + + byte[] buffer = new byte[256]; + int totalRead = 0; + int bytesRead; + + while ((bytesRead = zlibStream.Read(buffer, totalRead, buffer.Length - totalRead)) > 0) + { + totalRead += bytesRead; + } + + string result = Encoding.UTF8.GetString(buffer, 0, totalRead); + Assert.Equal(HelloWorldText, result); + } + + [Fact] + public void Decompress_ReadByte_ReturnsCorrectFirstByte() + { + using var compressedStream = new MemoryStream(ZLibCompressedHelloWorld); + using var zlibStream = new ZLibStream(compressedStream, CompressionMode.Decompress); + + int firstByte = zlibStream.ReadByte(); + + Assert.Equal((int)'H', firstByte); + } + + [Fact] + public void Compress_ThenDecompress_RoundTrips() + { + byte[] original = Encoding.UTF8.GetBytes("The quick brown fox jumps over the lazy dog."); + + // Compress + byte[] compressed; + using (var compressedStream = new MemoryStream()) + { + using (var zlibStream = new ZLibStream(compressedStream, CompressionLevel.Optimal, leaveOpen: true)) + { + zlibStream.Write(original, 0, original.Length); + } + + compressed = compressedStream.ToArray(); + } + + // Verify zlib header is present + Assert.Equal(0x78, compressed[0]); + + // Decompress + byte[] decompressed; + using (var compressedStream = new MemoryStream(compressed)) + { + using (var zlibStream = new ZLibStream(compressedStream, CompressionMode.Decompress)) + { + using (var resultStream = new MemoryStream()) + { + zlibStream.CopyTo(resultStream); + decompressed = resultStream.ToArray(); + } + } + } + + Assert.Equal(original, decompressed); + } + + [Fact] + public void Compress_EmptyData_RoundTrips() + { + byte[] original = Array.Empty(); + + // Compress + byte[] compressed; + using (var compressedStream = new MemoryStream()) + { + using (var zlibStream = new ZLibStream(compressedStream, CompressionLevel.Optimal, leaveOpen: true)) + { + zlibStream.Write(original, 0, original.Length); + } + + compressed = compressedStream.ToArray(); + } + + // Decompress + byte[] decompressed; + using (var compressedStream = new MemoryStream(compressed)) + { + using (var zlibStream = new ZLibStream(compressedStream, CompressionMode.Decompress)) + { + using (var resultStream = new MemoryStream()) + { + zlibStream.CopyTo(resultStream); + decompressed = resultStream.ToArray(); + } + } + } + + Assert.Equal(original, decompressed); + } + + [Fact] + public void Compress_LargeData_RoundTrips() + { + // Create a large repetitive payload (~10KB) + var sb = new StringBuilder(); + for (int i = 0; i < 500; i++) + { + sb.AppendLine($"Line {i}: The quick brown fox jumps over the lazy dog."); + } + + byte[] original = Encoding.UTF8.GetBytes(sb.ToString()); + + // Compress + byte[] compressed; + using (var compressedStream = new MemoryStream()) + { + using (var zlibStream = new ZLibStream(compressedStream, CompressionLevel.Optimal, leaveOpen: true)) + { + zlibStream.Write(original, 0, original.Length); + } + + compressed = compressedStream.ToArray(); + } + + // Should actually be smaller due to repetition + Assert.True(compressed.Length < original.Length); + + // Decompress + byte[] decompressed; + using (var compressedStream = new MemoryStream(compressed)) + { + using (var zlibStream = new ZLibStream(compressedStream, CompressionMode.Decompress)) + { + using (var resultStream = new MemoryStream()) + { + zlibStream.CopyTo(resultStream); + decompressed = resultStream.ToArray(); + } + } + } + + Assert.Equal(original, decompressed); + } + + [Fact] + public void Decompress_InvalidHeader_ThrowsInvalidDataException() + { + // Invalid zlib header — checksum fails + byte[] invalidData = { 0x78, 0x00, 0x00, 0x00 }; + + using var stream = new MemoryStream(invalidData); + using var zlibStream = new ZLibStream(stream, CompressionMode.Decompress); + + Assert.Throws(() => zlibStream.ReadByte()); + } + + [Fact] + public void Decompress_NonDeflateCompressionMethod_ThrowsInvalidDataException() + { + // CMF = 0x09 means compression method 9 (not deflate) + // FLG must satisfy (CMF * 256 + FLG) % 31 == 0 + // 0x09 * 256 = 2304, 2304 % 31 = 10, so FLG = 31 - 10 = 21 = 0x15 + byte[] invalidData = { 0x09, 0x15, 0x00, 0x00 }; + + using var stream = new MemoryStream(invalidData); + using var zlibStream = new ZLibStream(stream, CompressionMode.Decompress); + + Assert.Throws(() => zlibStream.ReadByte()); + } + + [Fact] + public void Decompress_TruncatedHeader_ThrowsInvalidDataException() + { + byte[] truncatedData = { 0x78 }; + + using var stream = new MemoryStream(truncatedData); + using var zlibStream = new ZLibStream(stream, CompressionMode.Decompress); + + Assert.Throws(() => zlibStream.ReadByte()); + } + + [Fact] + public void Constructor_NullStream_ThrowsArgumentNullException() + { +#pragma warning disable CS8625 // Cannot convert null literal to non-nullable reference type. + Assert.Throws(() => new ZLibStream(null, CompressionMode.Decompress)); +#pragma warning restore CS8625 + } + + [Fact] + public void CanRead_DecompressMode_ReturnsTrue() + { + using var stream = new MemoryStream(ZLibCompressedHelloWorld); + using var zlibStream = new ZLibStream(stream, CompressionMode.Decompress); + + Assert.True(zlibStream.CanRead); + Assert.False(zlibStream.CanWrite); + Assert.False(zlibStream.CanSeek); + } + + [Fact] + public void CanWrite_CompressMode_ReturnsTrue() + { + using var stream = new MemoryStream(); + using var zlibStream = new ZLibStream(stream, CompressionMode.Compress); + + Assert.True(zlibStream.CanWrite); + Assert.False(zlibStream.CanRead); + Assert.False(zlibStream.CanSeek); + } + + [Fact] + public void Write_InDecompressMode_ThrowsInvalidOperationException() + { + using var stream = new MemoryStream(ZLibCompressedHelloWorld); + using var zlibStream = new ZLibStream(stream, CompressionMode.Decompress); + + Assert.Throws(() => zlibStream.Write(new byte[] { 1 }, 0, 1)); + } + + [Fact] + public void Read_InCompressMode_ThrowsInvalidOperationException() + { + using var stream = new MemoryStream(); + using var zlibStream = new ZLibStream(stream, CompressionMode.Compress); + + Assert.Throws(() => zlibStream.Read(new byte[1], 0, 1)); + } + + [Fact] + public void Seek_ThrowsNotSupportedException() + { + using var stream = new MemoryStream(ZLibCompressedHelloWorld); + using var zlibStream = new ZLibStream(stream, CompressionMode.Decompress); + + Assert.Throws(() => zlibStream.Seek(0, SeekOrigin.Begin)); + } + + [Fact] + public void Length_ThrowsNotSupportedException() + { + using var stream = new MemoryStream(ZLibCompressedHelloWorld); + using var zlibStream = new ZLibStream(stream, CompressionMode.Decompress); + + Assert.Throws(() => _ = zlibStream.Length); + } + + [Fact] + public void Dispose_ThenRead_ThrowsObjectDisposedException() + { + var stream = new MemoryStream(ZLibCompressedHelloWorld); + var zlibStream = new ZLibStream(stream, CompressionMode.Decompress); + + zlibStream.Dispose(); + + Assert.Throws(() => zlibStream.Read(new byte[1], 0, 1)); + } + + [Fact] + public void LeaveOpen_True_DoesNotDisposeBaseStream() + { + var stream = new MemoryStream(ZLibCompressedHelloWorld); + var zlibStream = new ZLibStream(stream, CompressionMode.Decompress, leaveOpen: true); + + zlibStream.Dispose(); + + // Stream should still be accessible + Assert.True(stream.CanRead); + } + + [Fact] + public void LeaveOpen_False_DisposesBaseStream() + { + var stream = new MemoryStream(ZLibCompressedHelloWorld); + var zlibStream = new ZLibStream(stream, CompressionMode.Decompress, leaveOpen: false); + + zlibStream.Dispose(); + + // Stream should be disposed + Assert.False(stream.CanRead); + } + + [Fact] + public void BaseStream_ReturnsUnderlyingStream() + { + var stream = new MemoryStream(ZLibCompressedHelloWorld); + using var zlibStream = new ZLibStream(stream, CompressionMode.Decompress); + + Assert.Same(stream, zlibStream.BaseStream); + } + + [Fact] + public void CompressedOutput_HasValidZLibHeader() + { + byte[] compressed; + using (var output = new MemoryStream()) + { + using (var zlibStream = new ZLibStream(output, CompressionLevel.Optimal, leaveOpen: true)) + { + byte[] data = Encoding.UTF8.GetBytes("test"); + zlibStream.Write(data, 0, data.Length); + } + + compressed = output.ToArray(); + } + + // Verify CMF byte: deflate (method 8), window size 15 (CINFO 7) + Assert.Equal(0x78, compressed[0]); + + // Verify header checksum: (CMF * 256 + FLG) % 31 == 0 + int headerCheck = (compressed[0] * 256) + compressed[1]; + Assert.Equal(0, headerCheck % 31); + } + + [Fact] + public void ComputeAdler32_EmptyInput_ReturnsOne() + { + uint result = ZLibStream.ComputeAdler32(Array.Empty()); + + // For empty input, A=1, B=0, so Adler32 = (0 << 16) | 1 = 1 + Assert.Equal(1u, result); + } + + [Fact] + public void ComputeAdler32_KnownInput_ReturnsExpectedChecksum() + { + // "Wikipedia" Adler-32 is well-known: 0x11E60398 + byte[] data = Encoding.ASCII.GetBytes("Wikipedia"); + uint result = ZLibStream.ComputeAdler32(data); + + Assert.Equal(0x11E60398u, result); + } + + [Fact] + public void ComputeAdler32_WithOffset_ComputesCorrectly() + { + byte[] data = Encoding.ASCII.GetBytes("XXWikipediaYY"); + // Offset 2, count 9 = "Wikipedia" + uint result = ZLibStream.ComputeAdler32(data, 2, 9); + + Assert.Equal(0x11E60398u, result); + } + + [Fact] + public void Compress_Fastest_ProducesValidOutput() + { + byte[] original = Encoding.UTF8.GetBytes("test data for fastest compression level"); + + byte[] compressed; + using (var compressedStream = new MemoryStream()) + { + using (var zlibStream = new ZLibStream(compressedStream, CompressionLevel.Fastest, leaveOpen: true)) + { + zlibStream.Write(original, 0, original.Length); + } + + compressed = compressedStream.ToArray(); + } + + // Verify valid header + Assert.Equal(0x78, compressed[0]); + int headerCheck = (compressed[0] * 256) + compressed[1]; + Assert.Equal(0, headerCheck % 31); + + // Verify decompression round-trip + byte[] decompressed; + using (var compressedStream = new MemoryStream(compressed)) + { + using (var zlibStream = new ZLibStream(compressedStream, CompressionMode.Decompress)) + { + using (var resultStream = new MemoryStream()) + { + zlibStream.CopyTo(resultStream); + decompressed = resultStream.ToArray(); + } + } + } + + Assert.Equal(original, decompressed); + } + + [Fact] + public void Compress_NoCompression_ProducesValidOutput() + { + byte[] original = Encoding.UTF8.GetBytes("test data for no compression level"); + + byte[] compressed; + using (var compressedStream = new MemoryStream()) + { + using (var zlibStream = new ZLibStream(compressedStream, CompressionLevel.NoCompression, leaveOpen: true)) + { + zlibStream.Write(original, 0, original.Length); + } + + compressed = compressedStream.ToArray(); + } + + // Verify valid header + Assert.Equal(0x78, compressed[0]); + int headerCheck = (compressed[0] * 256) + compressed[1]; + Assert.Equal(0, headerCheck % 31); + + // Verify decompression round-trip + byte[] decompressed; + using (var compressedStream = new MemoryStream(compressed)) + { + using (var zlibStream = new ZLibStream(compressedStream, CompressionMode.Decompress)) + { + using (var resultStream = new MemoryStream()) + { + zlibStream.CopyTo(resultStream); + decompressed = resultStream.ToArray(); + } + } + } + + Assert.Equal(original, decompressed); + } + + [Fact] + public void Compress_WritesCorrectAdler32Trailer() + { + // "Wikipedia" has the well-known Adler-32 value 0x11E60398. + // Verify that the last 4 bytes of the compressed output match the + // Adler-32 of the original data in big-endian order. + byte[] original = Encoding.ASCII.GetBytes("Wikipedia"); + + byte[] compressed; + using (var output = new MemoryStream()) + { + using (var zlibStream = new ZLibStream(output, CompressionLevel.Optimal, leaveOpen: true)) + { + zlibStream.Write(original, 0, original.Length); + } + + compressed = output.ToArray(); + } + + Assert.True(compressed.Length >= 6, "Compressed output too short to contain header + trailer."); + + // Parse the 4-byte big-endian Adler-32 trailer + uint trailer = ((uint)compressed[^4] << 24) + | ((uint)compressed[^3] << 16) + | ((uint)compressed[^2] << 8) + | compressed[^1]; + + uint expected = ZLibStream.ComputeAdler32(original); + Assert.Equal(0x11E60398u, expected); // sanity-check known test vector + Assert.Equal(expected, trailer); + } + + /// + /// Simulates the GIDS format: 4-byte GIDS header (0x01, 0x00 magic + + /// 2-byte LE uncompressed length) followed by standard zlib-compressed data. + /// Verifies the decompression approach used in PivSession.KeyPairs.DecompressGids. + /// + [Fact] + public void Decompress_GidsFormat_WithHeaderStripping_Works() + { + byte[] original = Encoding.UTF8.GetBytes("Certificate data for GIDS test"); + + // Compress with zlib + byte[] zlibCompressed; + using (var compressedStream = new MemoryStream()) + { + using (var zlibStream = new ZLibStream(compressedStream, CompressionLevel.Optimal, leaveOpen: true)) + { + zlibStream.Write(original, 0, original.Length); + } + + zlibCompressed = compressedStream.ToArray(); + } + + // Prepend the 4-byte GIDS header: magic (0x01, 0x00) + LE uncompressed length + int uncompressedLength = original.Length; + byte[] gidsData = new byte[4 + zlibCompressed.Length]; + gidsData[0] = 0x01; + gidsData[1] = 0x00; + gidsData[2] = (byte)(uncompressedLength & 0xFF); + gidsData[3] = (byte)((uncompressedLength >> 8) & 0xFF); + Buffer.BlockCopy(zlibCompressed, 0, gidsData, 4, zlibCompressed.Length); + + // Decompress like PivSession.KeyPairs.DecompressGids does: + // strip 4-byte header, then pass to ZLibStream + const int gidsHeaderLength = 4; + using (var dataStream = new MemoryStream(gidsData, gidsHeaderLength, gidsData.Length - gidsHeaderLength)) + { + using (var decompressor = new ZLibStream(dataStream, CompressionMode.Decompress)) + { + using (var resultStream = new MemoryStream()) + { + decompressor.CopyTo(resultStream); + byte[] decompressed = resultStream.ToArray(); + + Assert.Equal(original, decompressed); + } + } + } + } + } +}