From 751c5269a702b8876d66461b789539ef792438d9 Mon Sep 17 00:00:00 2001 From: "Moises E. Benzan M." Date: Fri, 25 Jul 2025 14:58:35 -0400 Subject: [PATCH] Feat: Adds HLKX and OPC format support for the Azure Sign Tool. --- Directory.Packages.props | 7 + .../AzureSign.Core.Opc.csproj | 21 ++ .../Containers/HlkxContainer.cs | 310 ++++++++++++++++++ .../Containers/IHlkxContainer.cs | 64 ++++ src/AzureSign.Core.Opc/Models/OpcPart.cs | 59 ++++ .../Models/OpcRelationship.cs | 28 ++ .../Signatures/HlkxSigner.cs | 291 ++++++++++++++++ .../Signatures/IHlkxSigner.cs | 35 ++ .../Signatures/OpcDigitalSignature.cs | 163 +++++++++ .../Signatures/OpcManifestBuilder.cs | 171 ++++++++++ .../AuthenticodeKeyVaultSigner.cs | 56 +++- src/AzureSign.Core/AzureSign.Core.csproj | 4 + src/AzureSign.Core/SipExtensionFactory.cs | 7 +- src/AzureSignTool/AzureSignTool.csproj | 1 + .../AzureSign.Core.Opc.Tests.csproj | 23 ++ .../Containers/HlkxContainerTests.cs | 135 ++++++++ .../Signatures/HlkxSignerTests.cs | 222 +++++++++++++ test/AzureSign.Core.Opc.Tests/TestContext.cs | 69 ++++ .../TestData/SyntheticHlkxGenerator.cs | 294 +++++++++++++++++ 19 files changed, 1956 insertions(+), 4 deletions(-) create mode 100644 src/AzureSign.Core.Opc/AzureSign.Core.Opc.csproj create mode 100644 src/AzureSign.Core.Opc/Containers/HlkxContainer.cs create mode 100644 src/AzureSign.Core.Opc/Containers/IHlkxContainer.cs create mode 100644 src/AzureSign.Core.Opc/Models/OpcPart.cs create mode 100644 src/AzureSign.Core.Opc/Models/OpcRelationship.cs create mode 100644 src/AzureSign.Core.Opc/Signatures/HlkxSigner.cs create mode 100644 src/AzureSign.Core.Opc/Signatures/IHlkxSigner.cs create mode 100644 src/AzureSign.Core.Opc/Signatures/OpcDigitalSignature.cs create mode 100644 src/AzureSign.Core.Opc/Signatures/OpcManifestBuilder.cs create mode 100644 test/AzureSign.Core.Opc.Tests/AzureSign.Core.Opc.Tests.csproj create mode 100644 test/AzureSign.Core.Opc.Tests/Containers/HlkxContainerTests.cs create mode 100644 test/AzureSign.Core.Opc.Tests/Signatures/HlkxSignerTests.cs create mode 100644 test/AzureSign.Core.Opc.Tests/TestContext.cs create mode 100644 test/AzureSign.Core.Opc.Tests/TestData/SyntheticHlkxGenerator.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 19ae67d..ec62838 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -6,8 +6,12 @@ + + + + @@ -15,7 +19,10 @@ + + + \ No newline at end of file diff --git a/src/AzureSign.Core.Opc/AzureSign.Core.Opc.csproj b/src/AzureSign.Core.Opc/AzureSign.Core.Opc.csproj new file mode 100644 index 0000000..bda844e --- /dev/null +++ b/src/AzureSign.Core.Opc/AzureSign.Core.Opc.csproj @@ -0,0 +1,21 @@ + + + + net8.0 + enable + enable + link + + v + true + true + MIT + + + + + + + + + \ No newline at end of file diff --git a/src/AzureSign.Core.Opc/Containers/HlkxContainer.cs b/src/AzureSign.Core.Opc/Containers/HlkxContainer.cs new file mode 100644 index 0000000..4b3f022 --- /dev/null +++ b/src/AzureSign.Core.Opc/Containers/HlkxContainer.cs @@ -0,0 +1,310 @@ +using System.IO.Compression; +using System.Text; +using System.Xml; +using AzureSign.Core.Opc.Models; + +namespace AzureSign.Core.Opc.Containers; + +/// +/// ZIP-based implementation of HLKX OPC container. +/// +public class HlkxContainer : IHlkxContainer +{ + private readonly string _filePath; + private readonly ZipArchive _archive; + private readonly Dictionary _parts; + private readonly List _relationships; + private bool _modified; + private bool _disposed; + + private HlkxContainer(string filePath, ZipArchive archive) + { + _filePath = filePath; + _archive = archive; + _parts = new Dictionary(); + _relationships = new List(); + _modified = false; + + LoadContainer(); + } + + /// + /// Opens an existing HLKX file for reading and modification. + /// + public static HlkxContainer Open(string filePath) + { + if (!File.Exists(filePath)) + throw new FileNotFoundException($"HLKX file not found: {filePath}"); + + var archive = ZipFile.Open(filePath, ZipArchiveMode.Update); + return new HlkxContainer(filePath, archive); + } + + public IEnumerable GetParts() => _parts.Values; + + public OpcPart? GetPart(string path) => _parts.TryGetValue(path, out var part) ? part : null; + + public void AddPart(string path, byte[] content, string contentType) + { + var part = new OpcPart(path, contentType, content) { IsModified = true }; + _parts[path] = part; + _modified = true; + } + + public void UpdatePart(string path, byte[] content) + { + if (_parts.TryGetValue(path, out var part)) + { + part.UpdateContent(content); + _modified = true; + } + else + { + throw new InvalidOperationException($"Part not found: {path}"); + } + } + + public IEnumerable GetRelationships() => _relationships; + + public void AddRelationship(string target, string relationshipType, string? id = null) + { + id ??= GenerateRelationshipId(); + var relationship = new OpcRelationship(id, relationshipType, target); + _relationships.Add(relationship); + _modified = true; + } + + public void AddSignatureParts(Dictionary signatureParts) + { + foreach (var kvp in signatureParts) + { + var contentType = GetSignaturePartContentType(kvp.Key); + AddPart(kvp.Key, kvp.Value, contentType); + } + } + + public void RemoveAllSignatures() + { + // Remove signature-related parts + var signatureParts = _parts.Keys + .Where(path => path.StartsWith("/package/services/digital-signature/")) + .ToList(); + + foreach (var partPath in signatureParts) + { + _parts.Remove(partPath); + } + + // Remove signature relationships + var signatureRelationships = _relationships + .Where(r => r.RelationshipType.Contains("digital-signature")) + .ToList(); + + foreach (var rel in signatureRelationships) + { + _relationships.Remove(rel); + } + + if (signatureParts.Any() || signatureRelationships.Any()) + { + _modified = true; + } + } + + public bool HasSignatures => _parts.Keys.Any(path => path.StartsWith("/package/services/digital-signature/")); + + public void UpdateContentTypes() + { + var contentTypesXml = BuildContentTypesXml(); + AddPart("/[Content_Types].xml", Encoding.UTF8.GetBytes(contentTypesXml), + "application/vnd.openxmlformats-package.content-types+xml"); + } + + public void Save() + { + if (!_modified) return; + + // Update relationships + UpdateRelationshipsFile(); + + // Update content types + UpdateContentTypes(); + + // Write all modified parts to ZIP + foreach (var part in _parts.Values.Where(p => p.IsModified)) + { + WritePartToArchive(part); + } + + _modified = false; + } + + public void Dispose() + { + if (!_disposed) + { + Save(); + _archive?.Dispose(); + _disposed = true; + } + } + + private void LoadContainer() + { + // Load all parts from ZIP archive + foreach (var entry in _archive.Entries) + { + if (entry.FullName.EndsWith('/')) continue; // Skip directories + + var path = "/" + entry.FullName.Replace('\\', '/'); + var contentType = GetContentTypeFromPath(path); + + using var stream = entry.Open(); + using var memoryStream = new MemoryStream(); + stream.CopyTo(memoryStream); + var content = memoryStream.ToArray(); + + _parts[path] = new OpcPart(path, contentType, content); + } + + // Load relationships from _rels/.rels + LoadRelationships(); + } + + private void LoadRelationships() + { + var relsPart = GetPart("/_rels/.rels"); + if (relsPart == null) return; + + try + { + var doc = relsPart.GetContentAsXml(); + var nsManager = new XmlNamespaceManager(doc.NameTable); + nsManager.AddNamespace("r", "http://schemas.openxmlformats.org/package/2006/relationships"); + + var relationshipNodes = doc.SelectNodes("//r:Relationship", nsManager); + if (relationshipNodes == null) return; + + foreach (XmlNode node in relationshipNodes) + { + if (node.Attributes == null) continue; + + var id = node.Attributes["Id"]?.Value; + var type = node.Attributes["Type"]?.Value; + var target = node.Attributes["Target"]?.Value; + + if (id != null && type != null && target != null) + { + _relationships.Add(new OpcRelationship(id, type, target)); + } + } + } + catch (XmlException) + { + // Handle invalid XML gracefully + } + } + + private void UpdateRelationshipsFile() + { + var xml = BuildRelationshipsXml(); + var content = Encoding.UTF8.GetBytes(xml); + + if (_parts.ContainsKey("/_rels/.rels")) + { + UpdatePart("/_rels/.rels", content); + } + else + { + AddPart("/_rels/.rels", content, "application/vnd.openxmlformats-package.relationships+xml"); + } + } + + private string BuildRelationshipsXml() + { + var sb = new StringBuilder(); + sb.AppendLine(""); + sb.AppendLine(""); + + foreach (var rel in _relationships) + { + sb.AppendLine($" "); + } + + sb.AppendLine(""); + return sb.ToString(); + } + + private string BuildContentTypesXml() + { + var sb = new StringBuilder(); + sb.AppendLine(""); + sb.AppendLine(""); + + // Default extensions + sb.AppendLine(" "); + sb.AppendLine(" "); + sb.AppendLine(" "); + + // Signature-specific content types + sb.AppendLine(" "); + sb.AppendLine(" "); + sb.AppendLine(" "); + + // Override for specific parts + foreach (var part in _parts.Values) + { + if (part.Path.StartsWith("/hck/data/") && !part.Path.EndsWith(".xml")) + { + sb.AppendLine($" "); + } + } + + sb.AppendLine(""); + return sb.ToString(); + } + + private void WritePartToArchive(OpcPart part) + { + var entryName = part.Path.TrimStart('/'); + + // Remove existing entry if it exists + var existingEntry = _archive.GetEntry(entryName); + existingEntry?.Delete(); + + // Create new entry + var entry = _archive.CreateEntry(entryName); + using var stream = entry.Open(); + stream.Write(part.Content); + } + + private string GetContentTypeFromPath(string path) + { + return path switch + { + "/_rels/.rels" => "application/vnd.openxmlformats-package.relationships+xml", + "/[Content_Types].xml" => "application/vnd.openxmlformats-package.content-types+xml", + var p when p.EndsWith(".psdsor") => "application/vnd.openxmlformats-package.digital-signature-origin", + var p when p.EndsWith(".psdsxs") => "application/vnd.openxmlformats-package.digital-signature-xmlsignature+xml", + var p when p.EndsWith(".cer") => "application/vnd.openxmlformats-package.digital-signature-certificate", + _ => "application/octet" + }; + } + + private string GetSignaturePartContentType(string path) + { + return path switch + { + var p when p.EndsWith("origin.psdsor") => "application/vnd.openxmlformats-package.digital-signature-origin", + var p when p.EndsWith(".psdsxs") => "application/vnd.openxmlformats-package.digital-signature-xmlsignature+xml", + var p when p.EndsWith(".cer") => "application/vnd.openxmlformats-package.digital-signature-certificate", + var p when p.Contains("/_rels/") => "application/vnd.openxmlformats-package.relationships+xml", + _ => "application/octet" + }; + } + + private string GenerateRelationshipId() + { + return "R" + Guid.NewGuid().ToString("N")[..8].ToUpperInvariant(); + } +} \ No newline at end of file diff --git a/src/AzureSign.Core.Opc/Containers/IHlkxContainer.cs b/src/AzureSign.Core.Opc/Containers/IHlkxContainer.cs new file mode 100644 index 0000000..fcb77df --- /dev/null +++ b/src/AzureSign.Core.Opc/Containers/IHlkxContainer.cs @@ -0,0 +1,64 @@ +using AzureSign.Core.Opc.Models; + +namespace AzureSign.Core.Opc.Containers; + +/// +/// Interface for HLKX OPC container operations. +/// +public interface IHlkxContainer : IDisposable +{ + /// + /// Gets all parts in the container. + /// + IEnumerable GetParts(); + + /// + /// Gets a specific part by path. + /// + OpcPart? GetPart(string path); + + /// + /// Adds a new part to the container. + /// + void AddPart(string path, byte[] content, string contentType); + + /// + /// Updates an existing part's content. + /// + void UpdatePart(string path, byte[] content); + + /// + /// Gets all relationships in the container. + /// + IEnumerable GetRelationships(); + + /// + /// Adds a relationship to the container. + /// + void AddRelationship(string target, string relationshipType, string? id = null); + + /// + /// Adds signature-related parts to the container. + /// + void AddSignatureParts(Dictionary signatureParts); + + /// + /// Removes all existing signatures from the container. + /// + void RemoveAllSignatures(); + + /// + /// Gets whether the container has any signatures. + /// + bool HasSignatures { get; } + + /// + /// Updates the [Content_Types].xml file. + /// + void UpdateContentTypes(); + + /// + /// Saves all changes to the container. + /// + void Save(); +} \ No newline at end of file diff --git a/src/AzureSign.Core.Opc/Models/OpcPart.cs b/src/AzureSign.Core.Opc/Models/OpcPart.cs new file mode 100644 index 0000000..3eab731 --- /dev/null +++ b/src/AzureSign.Core.Opc/Models/OpcPart.cs @@ -0,0 +1,59 @@ +using System.Text; +using System.Xml; + +namespace AzureSign.Core.Opc.Models; + +/// +/// Represents a part (file) within an OPC package. +/// +public class OpcPart +{ + public string Path { get; } + public string ContentType { get; set; } + public byte[] Content { get; set; } + public bool IsModified { get; set; } + + public OpcPart(string path, string contentType, byte[] content) + { + Path = path ?? throw new ArgumentNullException(nameof(path)); + ContentType = contentType ?? throw new ArgumentNullException(nameof(contentType)); + Content = content ?? throw new ArgumentNullException(nameof(content)); + IsModified = false; + } + + /// + /// Gets the content as a UTF-8 string. + /// + public string GetContentAsString() + { + return Encoding.UTF8.GetString(Content); + } + + /// + /// Gets the content as an XML document. + /// + public XmlDocument GetContentAsXml() + { + var doc = new XmlDocument(); + doc.LoadXml(GetContentAsString()); + return doc; + } + + /// + /// Updates the content from a string, marking the part as modified. + /// + public void UpdateContent(string content) + { + Content = Encoding.UTF8.GetBytes(content); + IsModified = true; + } + + /// + /// Updates the content from bytes, marking the part as modified. + /// + public void UpdateContent(byte[] content) + { + Content = content ?? throw new ArgumentNullException(nameof(content)); + IsModified = true; + } +} \ No newline at end of file diff --git a/src/AzureSign.Core.Opc/Models/OpcRelationship.cs b/src/AzureSign.Core.Opc/Models/OpcRelationship.cs new file mode 100644 index 0000000..f4074a2 --- /dev/null +++ b/src/AzureSign.Core.Opc/Models/OpcRelationship.cs @@ -0,0 +1,28 @@ +namespace AzureSign.Core.Opc.Models; + +/// +/// Represents a relationship within an OPC package. +/// +public class OpcRelationship +{ + public string Id { get; } + public string RelationshipType { get; } + public string Target { get; } + public string SourceUri { get; } + + public OpcRelationship(string id, string relationshipType, string target, string sourceUri = "/") + { + Id = id ?? throw new ArgumentNullException(nameof(id)); + RelationshipType = relationshipType ?? throw new ArgumentNullException(nameof(relationshipType)); + Target = target ?? throw new ArgumentNullException(nameof(target)); + SourceUri = sourceUri ?? throw new ArgumentNullException(nameof(sourceUri)); + } + + /// + /// Creates a relationship XML element. + /// + public string ToXmlElement() + { + return $""; + } +} \ No newline at end of file diff --git a/src/AzureSign.Core.Opc/Signatures/HlkxSigner.cs b/src/AzureSign.Core.Opc/Signatures/HlkxSigner.cs new file mode 100644 index 0000000..2a8b5d1 --- /dev/null +++ b/src/AzureSign.Core.Opc/Signatures/HlkxSigner.cs @@ -0,0 +1,291 @@ +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using AzureSign.Core.Opc.Containers; +using AzureSign.Core.Opc.Models; +using Microsoft.Extensions.Logging; + +namespace AzureSign.Core.Opc.Signatures; + +/// +/// HLKX package signer implementation that works with Azure Key Vault HSM certificates. +/// +public class HlkxSigner : IHlkxSigner +{ + private const int S_OK = 0; + private const int E_FAIL = unchecked((int)0x80004005); + private const int E_INVALIDARG = unchecked((int)0x80070057); + + public async Task SignFileAsync( + string filePath, + AsymmetricAlgorithm signingAlgorithm, + X509Certificate2 certificate, + HashAlgorithmName hashAlgorithm, + ILogger? logger = null) + { + try + { + logger?.LogInformation("Starting HLKX signing process for: {FilePath}", filePath); + + if (!File.Exists(filePath)) + { + logger?.LogError("HLKX file not found: {FilePath}", filePath); + return E_INVALIDARG; + } + + using var container = HlkxContainer.Open(filePath); + + // Remove any existing signatures + container.RemoveAllSignatures(); + + // Get parts and relationships that need to be signed + var partsToSign = GetPartsRequiringSignature(container).ToList(); + var relationshipsToSign = GetRelationshipsRequiringSignature(container).ToList(); + + logger?.LogDebug("Found {PartCount} parts and {RelationshipCount} relationships to sign", + partsToSign.Count, relationshipsToSign.Count); + + // Create manifest + var manifestBuilder = new OpcManifestBuilder(hashAlgorithm); + var manifestXml = manifestBuilder.CreateManifest(partsToSign, relationshipsToSign); + + // Create temporary certificate for the signing structure + // This follows the fork's proven approach for HSM integration + using var tempCert = CreateTemporaryCertificate(signingAlgorithm, hashAlgorithm, logger); + + // Prepare SignedInfo for signing + var signedInfoData = PrepareSignedInfo(manifestXml, hashAlgorithm); + + // Sign with Azure Key Vault (remote operation) + logger?.LogDebug("Performing remote signing operation with Azure Key Vault"); + var signatureValue = await PerformRemoteSigningAsync(signingAlgorithm, signedInfoData, hashAlgorithm); + + // Create final digital signature + var signatureId = Guid.NewGuid().ToString("N"); + var signature = new OpcDigitalSignature( + signatureId, + signatureValue, + certificate, + hashAlgorithm, + manifestXml); + + // Add signature parts to container + var signatureParts = signature.GenerateSignatureParts(); + container.AddSignatureParts(signatureParts); + + // Add signature relationship to root + container.AddRelationship( + "/package/services/digital-signature/origin.psdsor", + "http://schemas.openxmlformats.org/package/2006/relationships/digital-signature/origin"); + + // Save the signed container + container.Save(); + + logger?.LogInformation("HLKX file signed successfully"); + return S_OK; + } + catch (Exception ex) + { + logger?.LogError(ex, "Failed to sign HLKX file: {FilePath}", filePath); + return E_FAIL; + } + } + + public async Task VerifyFileAsync(string filePath, ILogger? logger = null) + { + try + { + logger?.LogInformation("Verifying HLKX signature for: {FilePath}", filePath); + + if (!File.Exists(filePath)) + { + logger?.LogError("HLKX file not found: {FilePath}", filePath); + return false; + } + + using var container = HlkxContainer.Open(filePath); + + if (!container.HasSignatures) + { + logger?.LogWarning("HLKX file has no signatures: {FilePath}", filePath); + return false; + } + + // Basic verification - check if signature parts exist and are well-formed + // For full cryptographic verification, you would need to: + // 1. Parse the XML signature + // 2. Recalculate part digests + // 3. Verify the signature against the certificate + // 4. Check certificate chain validity + + var signatureParts = container.GetParts() + .Where(p => p.Path.StartsWith("/package/services/digital-signature/")) + .ToList(); + + logger?.LogDebug("Found {SignaturePartCount} signature parts", signatureParts.Count); + + // Minimal validation - ensure we have the required signature components + var hasOrigin = signatureParts.Any(p => p.Path.EndsWith("origin.psdsor")); + var hasXmlSignature = signatureParts.Any(p => p.Path.Contains("xml-signature") && p.Path.EndsWith(".psdsxs")); + var hasCertificate = signatureParts.Any(p => p.Path.Contains("certificate") && p.Path.EndsWith(".cer")); + + var isValid = hasOrigin && hasXmlSignature && hasCertificate; + + logger?.LogInformation("HLKX signature verification result: {IsValid}", isValid); + return isValid; + } + catch (Exception ex) + { + logger?.LogError(ex, "Failed to verify HLKX file: {FilePath}", filePath); + return false; + } + } + + /// + /// Gets the parts that require signing based on HLKX specification. + /// + private IEnumerable GetPartsRequiringSignature(IHlkxContainer container) + { + foreach (var part in container.GetParts()) + { + // Sign all data parts (critical for HLKX validation) + if (part.Path.StartsWith("/hck/data/") || + part.Path == "/_rels/.rels") + { + yield return part; + } + } + } + + /// + /// Gets the relationships that require signing based on HLKX specification. + /// + private IEnumerable GetRelationshipsRequiringSignature(IHlkxContainer container) + { + // Sign specific relationship types found in the analysis + var targetTypes = new HashSet + { + "http://microsoft.com/schemas/windows/kits/hardware/2010/streamdata", + "http://microsoft.com/schemas/windows/kits/hardware/2010/coredata", + "http://microsoft.com/schemas/windows/kits/hardware/2010/packageinfo", + "http://microsoft.com/shcemas/windows/kits/hardware/2010/packageinfo" // Note: typo in original spec + }; + + return container.GetRelationships() + .Where(r => targetTypes.Contains(r.RelationshipType)); + } + + /// + /// Creates a temporary self-signed certificate for the signing process. + /// This follows the fork's proven approach for HSM integration. + /// + private X509Certificate2 CreateTemporaryCertificate( + AsymmetricAlgorithm signingAlgorithm, + HashAlgorithmName hashAlgorithm, + ILogger? logger) + { + logger?.LogDebug("Creating temporary certificate for signing structure"); + + var rsa = signingAlgorithm as RSA ?? throw new ArgumentException("Only RSA algorithms are supported"); + + var request = new CertificateRequest( + "CN=TemporarySelfSignedHlkxCertificate", + rsa, + hashAlgorithm, + RSASignaturePadding.Pkcs1); + + var certificate = request.CreateSelfSigned( + DateTimeOffset.UtcNow.AddDays(-1), + DateTimeOffset.UtcNow.AddDays(7)); + + logger?.LogDebug("Created temporary certificate with thumbprint: {Thumbprint}", certificate.Thumbprint); + return certificate; + } + + /// + /// Prepares the SignedInfo data for signing. + /// + private byte[] PrepareSignedInfo(string manifestXml, HashAlgorithmName hashAlgorithm) + { + // Create SignedInfo element + var digestAlgorithm = GetDigestAlgorithmUrl(hashAlgorithm); + var signatureMethod = GetSignatureMethodUrl(hashAlgorithm); + + // Compute manifest digest + var manifestBytes = System.Text.Encoding.UTF8.GetBytes(manifestXml); + using var hasher = CreateHasher(hashAlgorithm); + var manifestDigest = Convert.ToBase64String(hasher.ComputeHash(manifestBytes)); + + var signedInfo = $@" + + + + + {manifestDigest} + +"; + + return System.Text.Encoding.UTF8.GetBytes(signedInfo); + } + + /// + /// Performs the actual signing operation using the Azure Key Vault RSA instance. + /// + private async Task PerformRemoteSigningAsync( + AsymmetricAlgorithm signingAlgorithm, + byte[] dataToSign, + HashAlgorithmName hashAlgorithm) + { + if (signingAlgorithm is RSA rsa) + { + // For RSA, we can use SignData directly + return rsa.SignData(dataToSign, hashAlgorithm, RSASignaturePadding.Pkcs1); + } + else if (signingAlgorithm is ECDsa ecdsa) + { + // For ECDSA, we need to hash first then sign + using var hasher = CreateHasher(hashAlgorithm); + var hash = hasher.ComputeHash(dataToSign); + return ecdsa.SignHash(hash); + } + else + { + throw new NotSupportedException($"Signing algorithm {signingAlgorithm.GetType().Name} is not supported"); + } + } + + private HashAlgorithm CreateHasher(HashAlgorithmName hashAlgorithm) + { + return hashAlgorithm.Name switch + { + "SHA1" => SHA1.Create(), + "SHA256" => SHA256.Create(), + "SHA384" => SHA384.Create(), + "SHA512" => SHA512.Create(), + _ => SHA256.Create() + }; + } + + private string GetDigestAlgorithmUrl(HashAlgorithmName hashAlgorithm) + { + return hashAlgorithm.Name switch + { + "SHA1" => "http://www.w3.org/2000/09/xmldsig#sha1", + "SHA256" => "http://www.w3.org/2001/04/xmlenc#sha256", + "SHA384" => "http://www.w3.org/2001/04/xmldsig-more#sha384", + "SHA512" => "http://www.w3.org/2001/04/xmlenc#sha512", + _ => "http://www.w3.org/2001/04/xmlenc#sha256" + }; + } + + private string GetSignatureMethodUrl(HashAlgorithmName hashAlgorithm) + { + return hashAlgorithm.Name switch + { + "SHA1" => "http://www.w3.org/2000/09/xmldsig#rsa-sha1", + "SHA256" => "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256", + "SHA384" => "http://www.w3.org/2001/04/xmldsig-more#rsa-sha384", + "SHA512" => "http://www.w3.org/2001/04/xmldsig-more#rsa-sha512", + _ => "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256" + }; + } +} \ No newline at end of file diff --git a/src/AzureSign.Core.Opc/Signatures/IHlkxSigner.cs b/src/AzureSign.Core.Opc/Signatures/IHlkxSigner.cs new file mode 100644 index 0000000..09c164b --- /dev/null +++ b/src/AzureSign.Core.Opc/Signatures/IHlkxSigner.cs @@ -0,0 +1,35 @@ +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Logging; + +namespace AzureSign.Core.Opc.Signatures; + +/// +/// Interface for HLKX package signing operations. +/// +public interface IHlkxSigner +{ + /// + /// Signs an HLKX file using the provided certificate and signing algorithm. + /// + /// Path to the HLKX file to sign + /// The asymmetric algorithm for signing (typically RSA from Azure Key Vault) + /// The X.509 certificate (public key only) + /// The hash algorithm to use for signing + /// Optional logger for diagnostic information + /// HRESULT indicating success (0) or failure + Task SignFileAsync( + string filePath, + AsymmetricAlgorithm signingAlgorithm, + X509Certificate2 certificate, + HashAlgorithmName hashAlgorithm, + ILogger? logger = null); + + /// + /// Verifies the signature of an HLKX file. + /// + /// Path to the HLKX file to verify + /// Optional logger for diagnostic information + /// True if the signature is valid, false otherwise + Task VerifyFileAsync(string filePath, ILogger? logger = null); +} \ No newline at end of file diff --git a/src/AzureSign.Core.Opc/Signatures/OpcDigitalSignature.cs b/src/AzureSign.Core.Opc/Signatures/OpcDigitalSignature.cs new file mode 100644 index 0000000..9c062d3 --- /dev/null +++ b/src/AzureSign.Core.Opc/Signatures/OpcDigitalSignature.cs @@ -0,0 +1,163 @@ +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; + +namespace AzureSign.Core.Opc.Signatures; + +/// +/// Represents an OPC digital signature with all its components. +/// +public class OpcDigitalSignature +{ + public string SignatureId { get; } + public byte[] SignatureValue { get; } + public X509Certificate2 Certificate { get; } + public DateTime SignatureTime { get; } + public HashAlgorithmName HashAlgorithm { get; } + public string ManifestXml { get; } + + public OpcDigitalSignature( + string signatureId, + byte[] signatureValue, + X509Certificate2 certificate, + HashAlgorithmName hashAlgorithm, + string manifestXml) + { + SignatureId = signatureId ?? throw new ArgumentNullException(nameof(signatureId)); + SignatureValue = signatureValue ?? throw new ArgumentNullException(nameof(signatureValue)); + Certificate = certificate ?? throw new ArgumentNullException(nameof(certificate)); + HashAlgorithm = hashAlgorithm; + ManifestXml = manifestXml ?? throw new ArgumentNullException(nameof(manifestXml)); + SignatureTime = DateTime.UtcNow; + } + + /// + /// Generates all OPC signature parts required for the package. + /// + public Dictionary GenerateSignatureParts() + { + var parts = new Dictionary(); + + // Certificate hash for filename + var certHash = GetCertificateHash(); + + // 1. Origin marker (empty file) + parts["/package/services/digital-signature/origin.psdsor"] = Array.Empty(); + + // 2. XML signature + var xmlSignature = CreateXmlSignature(); + parts[$"/package/services/digital-signature/xml-signature/{SignatureId}.psdsxs"] = xmlSignature; + + // 3. Certificate + parts[$"/package/services/digital-signature/certificate/{certHash}.cer"] = Certificate.RawData; + + // 4. Origin relationships + var originRels = CreateOriginRelationships(); + parts["/package/services/digital-signature/_rels/origin.psdsor.rels"] = originRels; + + // 5. Signature relationships + var sigRels = CreateSignatureRelationships(certHash); + parts[$"/package/services/digital-signature/xml-signature/_rels/{SignatureId}.psdsxs.rels"] = sigRels; + + return parts; + } + + /// + /// Creates the XML signature document based on the HLKX reference structure. + /// + private byte[] CreateXmlSignature() + { + var xml = $@" + + + + + + + {ComputeManifestDigest()} + + + {Convert.ToBase64String(SignatureValue)} + + {ManifestXml} + + + + YYYY-MM-DDThh:mm:ss.sTZD + {SignatureTime:yyyy-MM-ddTHH:mm:ss.fK} + + + + +"; + + return System.Text.Encoding.UTF8.GetBytes(xml); + } + + private byte[] CreateOriginRelationships() + { + var xml = $@" + + +"; + + return System.Text.Encoding.UTF8.GetBytes(xml); + } + + private byte[] CreateSignatureRelationships(string certHash) + { + var xml = $@" + + +"; + + return System.Text.Encoding.UTF8.GetBytes(xml); + } + + private string GetCertificateHash() + { + using var sha1 = SHA1.Create(); + var hash = sha1.ComputeHash(Certificate.RawData); + return Convert.ToHexString(hash); + } + + private string GetSignatureMethodAlgorithm() + { + return HashAlgorithm.Name switch + { + "SHA1" => "http://www.w3.org/2000/09/xmldsig#rsa-sha1", + "SHA256" => "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256", + "SHA384" => "http://www.w3.org/2001/04/xmldsig-more#rsa-sha384", + "SHA512" => "http://www.w3.org/2001/04/xmldsig-more#rsa-sha512", + _ => "http://www.w3.org/2000/09/xmldsig#rsa-sha1" + }; + } + + private string GetDigestAlgorithm() + { + return HashAlgorithm.Name switch + { + "SHA1" => "http://www.w3.org/2000/09/xmldsig#sha1", + "SHA256" => "http://www.w3.org/2001/04/xmlenc#sha256", + "SHA384" => "http://www.w3.org/2001/04/xmldsig-more#sha384", + "SHA512" => "http://www.w3.org/2001/04/xmlenc#sha512", + _ => "http://www.w3.org/2000/09/xmldsig#sha1" + }; + } + + private string ComputeManifestDigest() + { + var manifestBytes = System.Text.Encoding.UTF8.GetBytes(ManifestXml); + + using System.Security.Cryptography.HashAlgorithm hasher = HashAlgorithm.Name switch + { + "SHA1" => SHA1.Create(), + "SHA256" => SHA256.Create(), + "SHA384" => SHA384.Create(), + "SHA512" => SHA512.Create(), + _ => SHA1.Create() + }; + + var hash = hasher.ComputeHash(manifestBytes); + return Convert.ToBase64String(hash); + } +} \ No newline at end of file diff --git a/src/AzureSign.Core.Opc/Signatures/OpcManifestBuilder.cs b/src/AzureSign.Core.Opc/Signatures/OpcManifestBuilder.cs new file mode 100644 index 0000000..065955d --- /dev/null +++ b/src/AzureSign.Core.Opc/Signatures/OpcManifestBuilder.cs @@ -0,0 +1,171 @@ +using System.Security.Cryptography; +using System.Text; +using AzureSign.Core.Opc.Models; + +namespace AzureSign.Core.Opc.Signatures; + +/// +/// Builds OPC manifest XML for digital signatures. +/// +public class OpcManifestBuilder +{ + private readonly HashAlgorithmName _hashAlgorithm; + + public OpcManifestBuilder(HashAlgorithmName hashAlgorithm) + { + _hashAlgorithm = hashAlgorithm; + } + + /// + /// Creates the manifest XML containing references to parts and relationships. + /// + public string CreateManifest(IEnumerable partsToSign, IEnumerable relationshipsToSign) + { + var sb = new StringBuilder(); + sb.AppendLine(""); + + // Add part references + foreach (var part in partsToSign) + { + var digest = ComputePartDigest(part); + var contentType = part.ContentType; + + if (part.Path == "/_rels/.rels") + { + // Special handling for relationships file with canonicalization + sb.AppendLine($" "); + sb.AppendLine(" "); + sb.AppendLine(" "); + sb.AppendLine(" "); + sb.AppendLine($" "); + sb.AppendLine($" {digest}"); + sb.AppendLine(" "); + } + else + { + // Regular part reference + sb.AppendLine($" "); + sb.AppendLine($" "); + sb.AppendLine($" {digest}"); + sb.AppendLine(" "); + } + } + + // Add relationship references with transforms + if (relationshipsToSign.Any()) + { + var relationshipDigest = ComputeRelationshipDigest(relationshipsToSign); + sb.AppendLine(" "); + sb.AppendLine(" "); + sb.AppendLine(" "); + + // Add relationship group references based on HLKX analysis + foreach (var relType in GetUniqueRelationshipTypes(relationshipsToSign)) + { + sb.AppendLine($" "); + } + + sb.AppendLine(" "); + sb.AppendLine(" "); + sb.AppendLine(" "); + sb.AppendLine($" "); + sb.AppendLine($" {relationshipDigest}"); + sb.AppendLine(" "); + } + + sb.AppendLine(""); + return sb.ToString(); + } + + private string ComputePartDigest(OpcPart part) + { + byte[] contentToHash = part.Content; + + // Apply canonicalization for XML content if needed + if (part.Path == "/_rels/.rels" || part.ContentType.Contains("xml")) + { + contentToHash = ApplyXmlCanonicalization(part.Content); + } + + using var hasher = CreateHasher(); + var hash = hasher.ComputeHash(contentToHash); + return Convert.ToBase64String(hash); + } + + private string ComputeRelationshipDigest(IEnumerable relationships) + { + // Create filtered relationships XML for signing + var filteredXml = CreateFilteredRelationshipsXml(relationships); + var canonicalizedXml = ApplyXmlCanonicalization(Encoding.UTF8.GetBytes(filteredXml)); + + using var hasher = CreateHasher(); + var hash = hasher.ComputeHash(canonicalizedXml); + return Convert.ToBase64String(hash); + } + + private string CreateFilteredRelationshipsXml(IEnumerable relationships) + { + var sb = new StringBuilder(); + sb.AppendLine(""); + sb.AppendLine(""); + + foreach (var rel in relationships.OrderBy(r => r.Id)) + { + sb.AppendLine($" "); + } + + sb.AppendLine(""); + return sb.ToString(); + } + + private byte[] ApplyXmlCanonicalization(byte[] xmlContent) + { + try + { + var xml = Encoding.UTF8.GetString(xmlContent); + + // Simple canonicalization - normalize whitespace and ensure consistent formatting + // For production, you might want to use XmlDsigC14NTransform for full C14N compliance + var normalized = xml + .Replace("\r\n", "\n") + .Replace("\r", "\n") + .Trim(); + + return Encoding.UTF8.GetBytes(normalized); + } + catch + { + // If XML parsing fails, return original content + return xmlContent; + } + } + + private HashSet GetUniqueRelationshipTypes(IEnumerable relationships) + { + return relationships.Select(r => r.RelationshipType).ToHashSet(); + } + + private HashAlgorithm CreateHasher() + { + return _hashAlgorithm.Name switch + { + "SHA1" => SHA1.Create(), + "SHA256" => SHA256.Create(), + "SHA384" => SHA384.Create(), + "SHA512" => SHA512.Create(), + _ => SHA256.Create() + }; + } + + private string GetDigestAlgorithm() + { + return _hashAlgorithm.Name switch + { + "SHA1" => "http://www.w3.org/2000/09/xmldsig#sha1", + "SHA256" => "http://www.w3.org/2001/04/xmlenc#sha256", + "SHA384" => "http://www.w3.org/2001/04/xmldsig-more#sha384", + "SHA512" => "http://www.w3.org/2001/04/xmlenc#sha512", + _ => "http://www.w3.org/2001/04/xmlenc#sha256" + }; + } +} \ No newline at end of file diff --git a/src/AzureSign.Core/AuthenticodeKeyVaultSigner.cs b/src/AzureSign.Core/AuthenticodeKeyVaultSigner.cs index 7b2626e..15522e9 100644 --- a/src/AzureSign.Core/AuthenticodeKeyVaultSigner.cs +++ b/src/AzureSign.Core/AuthenticodeKeyVaultSigner.cs @@ -79,6 +79,13 @@ public AuthenticodeKeyVaultSigner(AsymmetricAlgorithm signingAlgorithm, X509Cert /// was set to however the current operating system does not support appending signatures. public unsafe int SignFile(ReadOnlySpan path, ReadOnlySpan description, ReadOnlySpan descriptionUrl, bool? pageHashing, ILogger? logger = null, bool appendSignature = false) { + // Check for HLKX files and handle them with OPC signing + var sipKind = SipExtensionFactory.GetSipKind(path); + if (sipKind == SipKind.Hlkx) + { + return SignHlkxFile(path, description, descriptionUrl, logger); + } + static char[] NullTerminate(ReadOnlySpan str) { char[] result = new char[str.Length + 1]; @@ -163,11 +170,11 @@ static char[] NullTerminate(ReadOnlySpan str) var signCallbackInfo = new SIGN_INFO(callbackPtr); logger?.LogTrace("Getting SIP Data"); - var sipKind = SipExtensionFactory.GetSipKind(path); + var sipKindForSipData = SipExtensionFactory.GetSipKind(path); void* sipData = (void*)0; IntPtr context = IntPtr.Zero; - switch (sipKind) + switch (sipKindForSipData) { case SipKind.Appx: APPX_SIP_CLIENT_DATA clientData; @@ -202,7 +209,7 @@ static char[] NullTerminate(ReadOnlySpan str) { Debug.Assert(mssign32.SignerFreeSignerContext(context) == 0); } - if (result == 0 && sipKind == SipKind.Appx) + if (result == 0 && sipKindForSipData == SipKind.Appx) { var state = ((APPX_SIP_CLIENT_DATA*)sipData)->pAppxSipState; if (state != IntPtr.Zero) @@ -276,5 +283,48 @@ private static unsafe void FillAppxExtension( clientData.pSignerParams->pSignCallBack = signInfo; } + +#if NET8_0_OR_GREATER + /// + /// Signs an HLKX file using OPC digital signatures. + /// + private int SignHlkxFile(ReadOnlySpan path, ReadOnlySpan description, ReadOnlySpan descriptionUrl, ILogger? logger) + { + try + { + var pathString = path.ToString(); + logger?.LogInformation("Signing HLKX file: {Path}", pathString); + + // Use the new OPC signer + var hlkxSigner = new AzureSign.Core.Opc.Signatures.HlkxSigner(); + var result = hlkxSigner.SignFileAsync(pathString, _signingAlgorithm, _signingCertificate, _fileDigestAlgorithm, logger).GetAwaiter().GetResult(); + + if (result == 0) + { + logger?.LogInformation("HLKX file signed successfully: {Path}", pathString); + } + else + { + logger?.LogError("Failed to sign HLKX file: {Path}, HRESULT: {Result:X8}", pathString, result); + } + + return result; + } + catch (Exception ex) + { + logger?.LogError(ex, "Exception occurred while signing HLKX file: {Path}", path.ToString()); + return unchecked((int)0x80004005); // E_FAIL + } + } +#else + /// + /// Signs an HLKX file using OPC digital signatures. + /// + private int SignHlkxFile(ReadOnlySpan path, ReadOnlySpan description, ReadOnlySpan descriptionUrl, ILogger? logger) + { + logger?.LogError("HLKX signing is only supported on .NET 8.0 or later"); + return unchecked((int)0x80004005); // E_FAIL + } +#endif } } diff --git a/src/AzureSign.Core/AzureSign.Core.csproj b/src/AzureSign.Core/AzureSign.Core.csproj index 172fb40..695d5c5 100644 --- a/src/AzureSign.Core/AzureSign.Core.csproj +++ b/src/AzureSign.Core/AzureSign.Core.csproj @@ -22,4 +22,8 @@ + + + + diff --git a/src/AzureSign.Core/SipExtensionFactory.cs b/src/AzureSign.Core/SipExtensionFactory.cs index 68a6280..139af4f 100644 --- a/src/AzureSign.Core/SipExtensionFactory.cs +++ b/src/AzureSign.Core/SipExtensionFactory.cs @@ -6,7 +6,8 @@ namespace AzureSign.Core internal enum SipKind { None, - Appx + Appx, + Hlkx } internal class SipExtensionFactory @@ -46,6 +47,10 @@ public static SipKind GetSipKind(ReadOnlySpan filePath) { return SipKind.Appx; } + if (extension.Equals(".hlkx", StringComparison.OrdinalIgnoreCase)) + { + return SipKind.Hlkx; + } return SipKind.None; } } diff --git a/src/AzureSignTool/AzureSignTool.csproj b/src/AzureSignTool/AzureSignTool.csproj index 6e5b588..88ea3f7 100644 --- a/src/AzureSignTool/AzureSignTool.csproj +++ b/src/AzureSignTool/AzureSignTool.csproj @@ -32,5 +32,6 @@ + diff --git a/test/AzureSign.Core.Opc.Tests/AzureSign.Core.Opc.Tests.csproj b/test/AzureSign.Core.Opc.Tests/AzureSign.Core.Opc.Tests.csproj new file mode 100644 index 0000000..25e4d3e --- /dev/null +++ b/test/AzureSign.Core.Opc.Tests/AzureSign.Core.Opc.Tests.csproj @@ -0,0 +1,23 @@ + + + + net8.0 + enable + enable + false + true + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/AzureSign.Core.Opc.Tests/Containers/HlkxContainerTests.cs b/test/AzureSign.Core.Opc.Tests/Containers/HlkxContainerTests.cs new file mode 100644 index 0000000..22c517f --- /dev/null +++ b/test/AzureSign.Core.Opc.Tests/Containers/HlkxContainerTests.cs @@ -0,0 +1,135 @@ +using AzureSign.Core.Opc.Containers; +using AzureSign.Core.Opc.Tests.TestData; +using FluentAssertions; +using Xunit; + +namespace AzureSign.Core.Opc.Tests.Containers; + +public class HlkxContainerTests : IDisposable +{ + private readonly string _tempFile; + + public HlkxContainerTests() + { + _tempFile = Path.GetTempFileName(); + + // Create synthetic HLKX file for testing + SyntheticHlkxGenerator.CreateMinimalHlkx(_tempFile); + } + + [Fact] + public void Open_ValidHlkxFile_ShouldLoadParts() + { + // Arrange & Act + using var container = HlkxContainer.Open(_tempFile); + + // Assert + var parts = container.GetParts().ToList(); + parts.Should().NotBeEmpty(); + + // Should have at least the basic OPC parts + parts.Should().Contain(p => p.Path == "/[Content_Types].xml"); + parts.Should().Contain(p => p.Path == "/_rels/.rels"); + + // Should have synthetic HCK data parts + parts.Should().Contain(p => p.Path == "/hck/data/PackageInfo.xml"); + parts.Should().Contain(p => p.Path == "/hck/data/synthetic-data-1"); + parts.Should().Contain(p => p.Path == "/hck/data/synthetic-data-2"); + } + + [Fact] + public void GetPart_ExistingPart_ShouldReturnPart() + { + // Arrange + using var container = HlkxContainer.Open(_tempFile); + + // Act + var part = container.GetPart("/_rels/.rels"); + + // Assert + part.Should().NotBeNull(); + part!.Path.Should().Be("/_rels/.rels"); + part.ContentType.Should().Be("application/vnd.openxmlformats-package.relationships+xml"); + } + + [Fact] + public void AddPart_NewPart_ShouldAddToContainer() + { + // Arrange + using var container = HlkxContainer.Open(_tempFile); + var testContent = "test content"u8.ToArray(); + + // Act + container.AddPart("/test/part.txt", testContent, "text/plain"); + + // Assert + var part = container.GetPart("/test/part.txt"); + part.Should().NotBeNull(); + part!.Content.Should().BeEquivalentTo(testContent); + part.ContentType.Should().Be("text/plain"); + } + + [Fact] + public void HasSignatures_UnsignedFile_ShouldReturnFalse() + { + // Arrange + using var container = HlkxContainer.Open(_tempFile); + + // Act & Assert + container.HasSignatures.Should().BeFalse(); + } + + [Fact] + public void AddSignatureParts_ValidParts_ShouldAddToContainer() + { + // Arrange + using var container = HlkxContainer.Open(_tempFile); + var signatureParts = new Dictionary + { + ["/package/services/digital-signature/origin.psdsor"] = Array.Empty(), + ["/package/services/digital-signature/xml-signature/test.psdsxs"] = "test"u8.ToArray() + }; + + // Act + container.AddSignatureParts(signatureParts); + + // Assert + container.HasSignatures.Should().BeTrue(); + container.GetPart("/package/services/digital-signature/origin.psdsor").Should().NotBeNull(); + container.GetPart("/package/services/digital-signature/xml-signature/test.psdsxs").Should().NotBeNull(); + } + + [Fact] + public void HasSignatures_PreSignedFile_ShouldReturnTrue() + { + // Arrange + var preSignedFile = Path.GetTempFileName(); + SyntheticHlkxGenerator.CreatePreSignedHlkx(preSignedFile); + + try + { + using var container = HlkxContainer.Open(preSignedFile); + + // Act & Assert + container.HasSignatures.Should().BeTrue(); + + // Should have signature parts + container.GetPart("/package/services/digital-signature/origin.psdsor").Should().NotBeNull(); + var xmlSigParts = container.GetParts().Where(p => p.Path.Contains("xml-signature") && p.Path.EndsWith(".psdsxs")); + xmlSigParts.Should().NotBeEmpty(); + } + finally + { + if (File.Exists(preSignedFile)) + File.Delete(preSignedFile); + } + } + + public void Dispose() + { + if (File.Exists(_tempFile)) + { + File.Delete(_tempFile); + } + } +} \ No newline at end of file diff --git a/test/AzureSign.Core.Opc.Tests/Signatures/HlkxSignerTests.cs b/test/AzureSign.Core.Opc.Tests/Signatures/HlkxSignerTests.cs new file mode 100644 index 0000000..d946ea6 --- /dev/null +++ b/test/AzureSign.Core.Opc.Tests/Signatures/HlkxSignerTests.cs @@ -0,0 +1,222 @@ +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using AzureSign.Core.Opc.Containers; +using AzureSign.Core.Opc.Signatures; +using AzureSign.Core.Opc.Tests.TestData; +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace AzureSign.Core.Opc.Tests.Signatures; + +public class HlkxSignerTests : IDisposable +{ + private readonly string _tempFile; + private readonly X509Certificate2 _testCertificate; + private readonly RSA _testRsa; + + public HlkxSignerTests() + { + _tempFile = Path.GetTempFileName(); + SyntheticHlkxGenerator.CreateMinimalHlkx(_tempFile); + + // Create a test certificate and RSA key pair + _testRsa = RSA.Create(2048); + var request = new CertificateRequest( + "CN=Test Certificate for HLKX Signing", + _testRsa, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + + _testCertificate = request.CreateSelfSigned( + DateTimeOffset.UtcNow.AddDays(-1), + DateTimeOffset.UtcNow.AddDays(30)); + } + + [Fact] + public async Task SignFileAsync_ValidHlkxFile_ShouldSucceed() + { + // Arrange + var signer = new HlkxSigner(); + + // Act + var result = await signer.SignFileAsync( + _tempFile, + _testRsa, + _testCertificate, + HashAlgorithmName.SHA256, + NullLogger.Instance); + + // Assert + result.Should().Be(0); // S_OK + } + + [Fact] + public void Container_CanLoadSyntheticFile() + { + // Test if the basic container loading works + using var container = HlkxContainer.Open(_tempFile); + var parts = container.GetParts().ToList(); + + parts.Should().NotBeEmpty(); + parts.Should().Contain(p => p.Path == "/[Content_Types].xml"); + parts.Should().Contain(p => p.Path == "/_rels/.rels"); + } + + [Fact] + public void ManifestBuilder_CanCreateBasicManifest() + { + // Test if manifest creation works with synthetic data + using var container = HlkxContainer.Open(_tempFile); + var parts = container.GetParts() + .Where(p => p.Path.StartsWith("/hck/data/")) + .ToList(); + var relationships = container.GetRelationships().ToList(); + + var manifestBuilder = new OpcManifestBuilder(HashAlgorithmName.SHA256); + + // This should not throw an exception + var manifest = manifestBuilder.CreateManifest(parts, relationships); + + manifest.Should().NotBeEmpty(); + manifest.Should().Contain("Manifest"); + } + + [Fact] + public async Task SignFileAsync_NonExistentFile_ShouldFail() + { + // Arrange + var signer = new HlkxSigner(); + var nonExistentFile = Path.Combine(Path.GetTempPath(), "nonexistent.hlkx"); + + // Act + var result = await signer.SignFileAsync( + nonExistentFile, + _testRsa, + _testCertificate, + HashAlgorithmName.SHA256, + NullLogger.Instance); + + // Assert + result.Should().NotBe(0); // Should fail + } + + [Fact] + public async Task VerifyFileAsync_UnsignedFile_ShouldReturnFalse() + { + // Arrange + var signer = new HlkxSigner(); + + // Act + var result = await signer.VerifyFileAsync(_tempFile, NullLogger.Instance); + + // Assert + result.Should().BeFalse(); + } + + [Fact] + public async Task VerifyFileAsync_SignedFile_ShouldReturnTrue() + { + // Arrange + var signer = new HlkxSigner(); + + // First sign the file + await signer.SignFileAsync( + _tempFile, + _testRsa, + _testCertificate, + HashAlgorithmName.SHA256, + NullLogger.Instance); + + // Act + var result = await signer.VerifyFileAsync(_tempFile, NullLogger.Instance); + + // Assert + result.Should().BeTrue(); + } + + [Fact] + public async Task VerifyFileAsync_PreSignedSyntheticFile_ShouldReturnTrue() + { + // Arrange + var preSignedFile = Path.GetTempFileName(); + SyntheticHlkxGenerator.CreatePreSignedHlkx(preSignedFile); + var signer = new HlkxSigner(); + + try + { + // Act + var result = await signer.VerifyFileAsync(preSignedFile, NullLogger.Instance); + + // Assert + result.Should().BeTrue(); + } + finally + { + if (File.Exists(preSignedFile)) + File.Delete(preSignedFile); + } + } + + [Fact] + public async Task SignFileAsync_InvalidHlkxFile_ShouldFail() + { + // Arrange + var invalidFile = Path.GetTempFileName(); + SyntheticHlkxGenerator.CreateInvalidHlkx(invalidFile); + var signer = new HlkxSigner(); + + try + { + // Act + var result = await signer.SignFileAsync( + invalidFile, + _testRsa, + _testCertificate, + HashAlgorithmName.SHA256, + NullLogger.Instance); + + // Assert + result.Should().NotBe(0); // Should fail + } + finally + { + if (File.Exists(invalidFile)) + File.Delete(invalidFile); + } + } + + [Theory] + [InlineData("SHA1")] + [InlineData("SHA256")] + [InlineData("SHA384")] + [InlineData("SHA512")] + public async Task SignFileAsync_DifferentHashAlgorithms_ShouldSucceed(string algorithmName) + { + // Arrange + var signer = new HlkxSigner(); + var hashAlgorithm = new HashAlgorithmName(algorithmName); + + // Act + var result = await signer.SignFileAsync( + _tempFile, + _testRsa, + _testCertificate, + hashAlgorithm, + NullLogger.Instance); + + // Assert + result.Should().Be(0); // S_OK + } + + public void Dispose() + { + if (File.Exists(_tempFile)) + { + File.Delete(_tempFile); + } + + _testCertificate?.Dispose(); + _testRsa?.Dispose(); + } +} \ No newline at end of file diff --git a/test/AzureSign.Core.Opc.Tests/TestContext.cs b/test/AzureSign.Core.Opc.Tests/TestContext.cs new file mode 100644 index 0000000..18ed889 --- /dev/null +++ b/test/AzureSign.Core.Opc.Tests/TestContext.cs @@ -0,0 +1,69 @@ +using AzureSign.Core.Opc.Tests.TestData; +using System.Reflection; + +namespace AzureSign.Core.Opc.Tests; + +/// +/// Provides context for test execution and synthetic test data management. +/// +public static class TestContext +{ + /// + /// Gets the directory containing test assets. + /// + public static string TestAssetsDirectory + { + get + { + var assemblyLocation = Assembly.GetExecutingAssembly().Location; + var assemblyDirectory = Path.GetDirectoryName(assemblyLocation)!; + return Path.Combine(assemblyDirectory, "TestAssets"); + } + } + + /// + /// Creates a temporary HLKX file for testing purposes. + /// + /// The type of HLKX file to create + /// Path to the temporary file + public static string CreateTempHlkxFile(HlkxTestFileType type = HlkxTestFileType.Minimal) + { + var tempFile = Path.GetTempFileName(); + + switch (type) + { + case HlkxTestFileType.Minimal: + SyntheticHlkxGenerator.CreateMinimalHlkx(tempFile); + break; + case HlkxTestFileType.PreSigned: + SyntheticHlkxGenerator.CreatePreSignedHlkx(tempFile); + break; + case HlkxTestFileType.Invalid: + SyntheticHlkxGenerator.CreateInvalidHlkx(tempFile); + break; + } + + return tempFile; + } +} + +/// +/// Types of synthetic HLKX test files that can be created. +/// +public enum HlkxTestFileType +{ + /// + /// A minimal but valid unsigned HLKX file + /// + Minimal, + + /// + /// A pre-signed HLKX file with synthetic signature data + /// + PreSigned, + + /// + /// An invalid HLKX file for error testing + /// + Invalid +} \ No newline at end of file diff --git a/test/AzureSign.Core.Opc.Tests/TestData/SyntheticHlkxGenerator.cs b/test/AzureSign.Core.Opc.Tests/TestData/SyntheticHlkxGenerator.cs new file mode 100644 index 0000000..38fdbe3 --- /dev/null +++ b/test/AzureSign.Core.Opc.Tests/TestData/SyntheticHlkxGenerator.cs @@ -0,0 +1,294 @@ +using System.IO.Compression; +using System.Text; + +namespace AzureSign.Core.Opc.Tests.TestData; + +/// +/// Generates synthetic HLKX files for testing without using actual internal data. +/// +public static class SyntheticHlkxGenerator +{ + /// + /// Creates a minimal but valid HLKX structure for testing. + /// + public static void CreateMinimalHlkx(string filePath) + { + // Use temporary file approach to avoid ZIP entry length issues + var tempFilePath = Path.GetTempFileName(); + try + { + using (var fileStream = File.Create(tempFilePath)) + using (var zip = new ZipArchive(fileStream, ZipArchiveMode.Create)) + { + // Create [Content_Types].xml + CreateContentTypesFile(zip); + + // Create _rels/.rels + CreateRootRelationshipsFile(zip); + + // Create synthetic HCK data parts + CreateSyntheticHckData(zip); + } + + // Copy to final destination + File.Copy(tempFilePath, filePath, true); + } + finally + { + if (File.Exists(tempFilePath)) + File.Delete(tempFilePath); + } + } + + /// + /// Creates a pre-signed HLKX for testing signature verification. + /// + public static void CreatePreSignedHlkx(string filePath) + { + // Use temporary file approach to avoid ZIP entry length issues + var tempFilePath = Path.GetTempFileName(); + try + { + using (var fileStream = File.Create(tempFilePath)) + using (var zip = new ZipArchive(fileStream, ZipArchiveMode.Create)) + { + // Create basic structure + CreateContentTypesFileWithSignatures(zip); + CreateRootRelationshipsFileWithSignatures(zip); + CreateSyntheticHckData(zip); + + // Create synthetic signature parts + CreateSyntheticSignatureParts(zip); + } + + // Copy to final destination + File.Copy(tempFilePath, filePath, true); + } + finally + { + if (File.Exists(tempFilePath)) + File.Delete(tempFilePath); + } + } + + private static void CreateContentTypesFile(ZipArchive zip) + { + var entry = zip.CreateEntry("[Content_Types].xml"); + using (var stream = entry.Open()) + using (var writer = new StreamWriter(stream, Encoding.UTF8)) + { + writer.Write(@" + + + + + + +"); + } + } + + private static void CreateContentTypesFileWithSignatures(ZipArchive zip) + { + var entry = zip.CreateEntry("[Content_Types].xml"); + using (var stream = entry.Open()) + using (var writer = new StreamWriter(stream, Encoding.UTF8)) + { + writer.Write(@" + + + + + + + + + +"); + } + } + + private static void CreateRootRelationshipsFile(ZipArchive zip) + { + var entry = zip.CreateEntry("_rels/.rels"); + using (var stream = entry.Open()) + using (var writer = new StreamWriter(stream, Encoding.UTF8)) + { + writer.Write(@" + + + + + +"); + } + } + + private static void CreateRootRelationshipsFileWithSignatures(ZipArchive zip) + { + var entry = zip.CreateEntry("_rels/.rels"); + using (var stream = entry.Open()) + using (var writer = new StreamWriter(stream, Encoding.UTF8)) + { + writer.Write(@" + + + + + + +"); + } + } + + private static void CreateSyntheticHckData(ZipArchive zip) + { + // Create PackageInfo.xml with synthetic hardware certification data + var packageInfoEntry = zip.CreateEntry("hck/data/PackageInfo.xml"); + using (var stream = packageInfoEntry.Open()) + using (var writer = new StreamWriter(stream, Encoding.UTF8)) + { + writer.Write(@" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +"); + } + + // Create synthetic binary data files + var dataEntry1 = zip.CreateEntry("hck/data/synthetic-data-1"); + using (var stream = dataEntry1.Open()) + { + var syntheticData = Encoding.UTF8.GetBytes("This is synthetic test data for stream data testing. It represents hardware certification test results but contains no actual internal data."); + stream.Write(syntheticData); + } + + var dataEntry2 = zip.CreateEntry("hck/data/synthetic-data-2"); + using (var stream = dataEntry2.Open()) + { + var syntheticData = Encoding.UTF8.GetBytes("This is synthetic core data for testing. It simulates the structure of hardware lab kit data without containing any real certification information."); + stream.Write(syntheticData); + } + + // Create synthetic telemetry data + var telemetryEntry = zip.CreateEntry("hck/telemetry/SyntheticTelemetry.txt"); + using (var stream = telemetryEntry.Open()) + using (var writer = new StreamWriter(stream, Encoding.UTF8)) + { + writer.Write("Synthetic telemetry data for testing purposes.\nTimestamp: 2024-01-01T12:00:00Z\nTest: SyntheticTest\nResult: Pass"); + } + } + + private static void CreateSyntheticSignatureParts(ZipArchive zip) + { + // Create origin marker + var originEntry = zip.CreateEntry("package/services/digital-signature/origin.psdsor"); + using (var stream = originEntry.Open()) + { + // Empty file as per OPC spec + } + + // Create synthetic XML signature + var signatureId = "synthetic12345"; + var xmlSigEntry = zip.CreateEntry($"package/services/digital-signature/xml-signature/{signatureId}.psdsxs"); + using (var stream = xmlSigEntry.Open()) + using (var writer = new StreamWriter(stream, Encoding.UTF8)) + { + writer.Write($@" + + + + + + + SyntheticDigestValueForTesting== + + + SyntheticSignatureValueForTestingPurposesOnly== + + + + + SyntheticHash1== + + + + + + YYYY-MM-DDThh:mm:ss.sTZD + 2024-01-01T12:00:00.0+00:00 + + + + +"); + } + + // Create synthetic certificate + var certHash = "SYNTHETICCERTHASH123456789ABCDEF"; + var certEntry = zip.CreateEntry($"package/services/digital-signature/certificate/{certHash}.cer"); + using (var stream = certEntry.Open()) + { + // Create a minimal synthetic certificate-like structure + var syntheticCert = Encoding.UTF8.GetBytes("SYNTHETIC-CERT-DATA-FOR-TESTING-NOT-A-REAL-CERTIFICATE"); + stream.Write(syntheticCert); + } + + // Create origin relationships + var originRelsEntry = zip.CreateEntry("package/services/digital-signature/_rels/origin.psdsor.rels"); + using (var stream = originRelsEntry.Open()) + using (var writer = new StreamWriter(stream, Encoding.UTF8)) + { + writer.Write($@" + + +"); + } + + // Create signature relationships + var sigRelsEntry = zip.CreateEntry($"package/services/digital-signature/xml-signature/_rels/{signatureId}.psdsxs.rels"); + using (var stream = sigRelsEntry.Open()) + using (var writer = new StreamWriter(stream, Encoding.UTF8)) + { + writer.Write($@" + + +"); + } + } + + /// + /// Creates an invalid HLKX file for testing error handling. + /// + public static void CreateInvalidHlkx(string filePath) + { + // Create a file that looks like HLKX but is corrupted + File.WriteAllText(filePath, "This is not a valid HLKX file - it's just text pretending to be a ZIP archive"); + } +} \ No newline at end of file