File: CatalogBuilder.cs
Web Access
Project: 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(FileInfo 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(FileInfo path)
    {
        if (path is null)
        {
            throw new ArgumentNullException(nameof(path));
        }

        File.WriteAllBytes(path.FullName, 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; }
    }
}