| File: CatalogBuilder.cs | Web Access |
| Project: src\arcade\src\Microsoft.DotNet.Build.Tasks.FileCatalog\Microsoft.DotNet.Build.Tasks.FileCatalog.csproj (Microsoft.DotNet.Build.Tasks.FileCatalog) |
// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System; using System.Collections.Generic; using System.Formats.Asn1; using System.IO; using System.Security.Cryptography; namespace Microsoft.DotNet.Build.Tasks.FileCatalog { /// <summary> /// Builds a Microsoft Catalog (.cat) file in pure managed code — no <c>makecat.exe</c>, /// no Windows SDK, no P/Invoke, cross-platform. /// /// The output is an <em>unsigned</em> PKCS#7 SignedData envelope (no signers, no /// certificates) whose encapsulated content is a Microsoft <c>CertificateTrustList</c> /// (szOID_CTL). This matches the artifact <c>makecat.exe</c> produces from a /// <c>CatalogVersion=2 / HashAlgorithms=SHA256</c> <c>.cdf</c>: a catalog ready to be /// Authenticode-signed by <c>signtool.exe</c> or the Arcade signing infrastructure /// (via <c>FileExtensionSignInfo</c>). /// /// For each cataloged file, two <c>TrustedSubject</c> members are emitted — one keyed by /// the raw SHA-1 hash and one by the raw SHA-256 hash — matching makecat V2 output. /// The output is fully deterministic: given the same inputs it is byte-for-byte identical. /// </summary> public sealed class CatalogBuilder { // Fixed, stable timestamp so catalogs are reproducible. makecat writes the build // time here, but the value is not meaningful for signing/verification. private static readonly DateTimeOffset s_defaultEffectiveTime = new(2000, 1, 1, 0, 0, 0, TimeSpan.Zero); private readonly List<CatalogEntry> _entries = new(); /// <summary> /// Catalog "this update" timestamp (<c>ctlThisUpdate</c>). Defaults to a fixed value /// so output is deterministic; override only when a specific timestamp is required. /// </summary> public DateTimeOffset? EffectiveTime { get; set; } /// <summary> /// Optional explicit 16-byte list identifier. When null (the default), a deterministic /// identifier is derived from the catalog members so output stays reproducible. /// </summary> public byte[]? ListIdentifier { get => _listIdentifier; set { if (value is not null && value.Length != ListIdentifierLength) { throw new ArgumentException( $"List identifier must be exactly {ListIdentifierLength} bytes.", nameof(value)); } _listIdentifier = value; } } private byte[]? _listIdentifier; private const int ListIdentifierLength = 16; /// <summary>Read-only view of the entries that have been added.</summary> public IReadOnlyList<CatalogEntry> Entries => _entries; /// <summary>Adds an in-memory entry.</summary> public CatalogBuilder Add(CatalogEntry entry) { if (entry is null) { throw new ArgumentNullException(nameof(entry)); } _entries.Add(entry); return this; } /// <summary>Adds a file by path.</summary> public CatalogBuilder AddFile(string filePath, string? name = null) => Add(CatalogEntry.FromFile(filePath, name)); /// <summary>Builds the DER-encoded .cat file bytes.</summary> public byte[] Build() { if (_entries.Count == 0) { throw new InvalidOperationException("Catalog must contain at least one entry."); } List<Member> members = BuildSortedMembers(); byte[] ctl = EncodeCertificateTrustList(members); return EncodePkcs7SignedDataEnvelope(ctl); } /// <summary>Builds and writes the catalog to <paramref name="path"/>.</summary> public void WriteTo(string path) { if (string.IsNullOrEmpty(path)) { throw new ArgumentException("Path must not be null or empty.", nameof(path)); } File.WriteAllBytes(path, Build()); } private List<Member> BuildSortedMembers() { List<Member> members = new(_entries.Count * 2); foreach (CatalogEntry entry in _entries) { // makecat V2 catalogs emit two members per file so the file is found // regardless of which hash the verifying component looks it up by: older // catalog/Authenticode code paths compute a SHA-1 file "tag" and search for a // SHA-1-keyed member, while newer ones use SHA-256. The SHA-1 member is purely // a legacy lookup key/thumbprint here - it is NOT a signature and carries no // digest, so SHA-1's cryptographic weakness (collision attacks) is irrelevant: // trust comes solely from the Authenticode signature applied over the whole // catalog afterward, which uses SHA-256. // SHA-1 member: identified by the raw SHA-1 hash, marker attribute only. members.Add(new Member(entry.Sha1, sha256Digest: null)); // SHA-256 member: identified by the raw SHA-256 hash, carries the digest. members.Add(new Member(entry.Sha256, sha256Digest: entry.Sha256)); } // makecat emits members sorted ascending by their raw identifier bytes, // intermixing the SHA-1 and SHA-256 members, and de-duplicates members that // share an identifier (e.g. files with identical content). members.Sort(static (a, b) => CompareBytes(a.Identifier, b.Identifier)); var deduped = new List<Member>(members.Count); foreach (Member member in members) { if (deduped.Count == 0 || CompareBytes(deduped[deduped.Count - 1].Identifier, member.Identifier) != 0) { deduped.Add(member); } } return deduped; } private byte[] EncodeCertificateTrustList(List<Member> members) { AsnWriter writer = new(AsnEncodingRules.DER); writer.PushSequence(); // version: DEFAULT v1 — omitted. // subjectUsage: SEQUENCE { OID szOID_CATALOG_LIST } writer.PushSequence(); writer.WriteObjectIdentifier(CatalogOids.CatalogList); writer.PopSequence(); // listIdentifier: OCTET STRING (deterministic unless caller overrides). writer.WriteOctetString(ListIdentifier ?? DeriveListIdentifier(members)); // sequenceNumber: omitted. // ctlThisUpdate: UTCTime writer.WriteUtcTime(EffectiveTime ?? s_defaultEffectiveTime); // ctlNextUpdate: omitted. // subjectAlgorithm: AlgorithmIdentifier { OID szOID_CATALOG_LIST_MEMBER_V2, NULL } writer.PushSequence(); writer.WriteObjectIdentifier(CatalogOids.CatalogListMemberV2); writer.WriteNull(); writer.PopSequence(); // trustedSubjects: SEQUENCE OF TrustedSubject writer.PushSequence(); foreach (Member member in members) { CatalogMemberEncoder.WriteTrustedSubject(writer, member.Identifier, member.Sha256Digest); } writer.PopSequence(); // ctlExtensions [0] EXPLICIT — omitted for a minimal catalog. writer.PopSequence(); return writer.Encode(); } private static byte[] EncodePkcs7SignedDataEnvelope(byte[] ctlContent) { AsnWriter writer = new(AsnEncodingRules.DER); // ContentInfo SEQUENCE writer.PushSequence(); writer.WriteObjectIdentifier(CatalogOids.SignedData); // content [0] EXPLICIT SignedData Asn1Tag explicit0 = new(TagClass.ContextSpecific, tagValue: 0, isConstructed: true); writer.PushSequence(explicit0); // SignedData SEQUENCE writer.PushSequence(); // version INTEGER 1 writer.WriteInteger(1); // digestAlgorithms SET OF AlgorithmIdentifier — empty (no signer yet). writer.PushSetOf(); writer.PopSetOf(); // encapContentInfo: SEQUENCE { OID szOID_CTL, [0] EXPLICIT <CTL DER bytes> } writer.PushSequence(); writer.WriteObjectIdentifier(CatalogOids.Ctl); writer.PushSequence(explicit0); // The Microsoft catalog format places the CertificateTrustList DER directly here, // not wrapped in an OCTET STRING as generic CMS content would be. writer.WriteEncodedValue(ctlContent); writer.PopSequence(explicit0); writer.PopSequence(); // certificates [0] IMPLICIT / crls [1] IMPLICIT — omitted (added at signing time). // signerInfos SET OF SignerInfo — empty (added at signing time). writer.PushSetOf(); writer.PopSetOf(); writer.PopSequence(); // SignedData writer.PopSequence(explicit0); // [0] EXPLICIT writer.PopSequence(); // ContentInfo return writer.Encode(); } /// <summary> /// Derives a stable 16-byte list identifier from the (already sorted) member /// identifiers, so the catalog is reproducible without a random value. /// </summary> private static byte[] DeriveListIdentifier(List<Member> members) { using IncrementalHash sha256 = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); foreach (Member member in members) { sha256.AppendData(member.Identifier); } byte[] hash = sha256.GetHashAndReset(); return hash.AsSpan(0, ListIdentifierLength).ToArray(); } private static int CompareBytes(byte[] x, byte[] y) => x.AsSpan().SequenceCompareTo(y.AsSpan()); private readonly struct Member { public Member(byte[] identifier, byte[]? sha256Digest) { Identifier = identifier; Sha256Digest = sha256Digest; } public byte[] Identifier { get; } public byte[]? Sha256Digest { get; } } } }