< Summary

Information
Class: AsiBackbone.Core.Integrity.AuditIntegrityVerifier
Assembly: AsiBackbone.Core
File(s): /home/runner/work/AsiBackbone/AsiBackbone/src/AsiBackbone.Core/Integrity/AuditIntegrityVerifier.cs
Line coverage
100%
Covered lines: 147
Uncovered lines: 0
Coverable lines: 147
Total lines: 266
Line coverage: 100%
Branch coverage
96%
Covered branches: 54
Total branches: 56
Branch coverage: 96.4%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
Verify(...)92.86%1414100%
ResolveExpectedPreviousHash(...)87.5%88100%
VerifyTip(...)100%88100%
VerifyLink(...)100%2626100%

File(s)

/home/runner/work/AsiBackbone/AsiBackbone/src/AsiBackbone.Core/Integrity/AuditIntegrityVerifier.cs

#LineLine coverage
 1using AsiBackbone.Core.Signing;
 2
 3namespace AsiBackbone.Core.Integrity;
 4
 5/// <summary>
 6/// Verifies provider-neutral append-only audit integrity chains.
 7/// </summary>
 8public static class AuditIntegrityVerifier
 9{
 10    /// <summary>
 11    /// Verifies that the supplied links form one continuous append-only chain in the supplied order.
 12    /// </summary>
 13    /// <param name="links">The links to verify, in chain order.</param>
 14    /// <param name="expectedChainId">The chain identifier every link must carry. Defaults to the first link's chain ide
 15    /// <param name="requireGenesis">When <see langword="true" />, the first link must be the genesis link at sequence 1
 16    /// <param name="expectedPreviousLinkHash">
 17    /// The link hash the first supplied link must point back to. Required when <paramref name="requireGenesis" /> is
 18    /// <see langword="false" /> and the first supplied link is not the genesis link, because a partial chain is anchore
 19    /// only by the hash of the link preceding it.
 20    /// </param>
 21    /// <param name="expectedTipLinkHash">When supplied, the final link's hash must equal this value, which detects a tr
 22    /// <param name="expectedTipSequence">When supplied, the final link's sequence must equal this value, which detects 
 23    /// <remarks>
 24    /// Link metadata is not part of the link hash, so metadata is not authenticated by chain verification and must not 
 25    /// relied on as tamper-evident.
 26    /// </remarks>
 27    public static AuditIntegrityVerificationResult Verify(
 28        IEnumerable<AuditIntegrityLink> links,
 29        string? expectedChainId = null,
 30        bool requireGenesis = true,
 31        string? expectedPreviousLinkHash = null,
 32        string? expectedTipLinkHash = null,
 33        long? expectedTipSequence = null)
 34    {
 2135        ArgumentNullException.ThrowIfNull(links);
 36
 2137        List<AuditIntegrityLink> orderedLinks = [.. links];
 38
 2139        if (orderedLinks.Count == 0)
 40        {
 141            return AuditIntegrityVerificationResult.Failed(
 142                AuditIntegrityVerificationCategory.EmptyChain,
 143                "integrity.chain-empty",
 144                "No integrity links were supplied.");
 45        }
 46
 2047        string chainId = string.IsNullOrWhiteSpace(expectedChainId)
 2048            ? orderedLinks[0].ChainId
 2049            : expectedChainId.Trim();
 2050        HashSet<long> observedSequences = [];
 2051        long expectedSequence = requireGenesis ? 1 : orderedLinks[0].Sequence;
 52
 2053        AuditIntegrityVerificationResult? anchorResult = ResolveExpectedPreviousHash(
 2054            orderedLinks[0],
 2055            requireGenesis,
 2056            expectedPreviousLinkHash,
 2057            out string expectedPreviousHash);
 58
 2059        if (anchorResult is not null)
 60        {
 161            return anchorResult;
 62        }
 63
 10264        foreach (AuditIntegrityLink link in orderedLinks)
 65        {
 3966            AuditIntegrityVerificationResult? result = VerifyLink(
 3967                link,
 3968                chainId,
 3969                expectedSequence,
 3970                expectedPreviousHash,
 3971                observedSequences,
 3972                requireGenesis);
 73
 3974            if (result is not null)
 75            {
 1476                return result;
 77            }
 78
 2579            _ = observedSequences.Add(link.Sequence);
 2580            expectedPreviousHash = link.LinkHash;
 2581            expectedSequence = link.Sequence + 1;
 82        }
 83
 584        AuditIntegrityLink tip = orderedLinks[^1];
 85
 586        return VerifyTip(tip, expectedTipLinkHash, expectedTipSequence)
 587            ?? AuditIntegrityVerificationResult.Valid(chainId, orderedLinks.Count, tip.LinkHash);
 1488    }
 89
 90    /// <summary>
 91    /// Determines the link hash the first supplied link must point back to.
 92    /// </summary>
 93    /// <remarks>
 94    /// A partial chain starting at sequence N greater than 1 points back to link N-1, whose hash is non-empty by
 95    /// construction. Seeding the expected previous hash with an empty string in that case both rejects genuine partial
 96    /// chains and accepts a forged restart whose links were rewritten to claim no predecessor, so the caller must suppl
 97    /// the anchoring hash instead.
 98    /// </remarks>
 99    private static AuditIntegrityVerificationResult? ResolveExpectedPreviousHash(
 100        AuditIntegrityLink firstLink,
 101        bool requireGenesis,
 102        string? expectedPreviousLinkHash,
 103        out string expectedPreviousHash)
 104    {
 20105        expectedPreviousHash = string.Empty;
 106
 20107        if (requireGenesis || firstLink.Sequence == 1)
 108        {
 16109            return string.IsNullOrWhiteSpace(expectedPreviousLinkHash)
 16110                ? null
 16111                : AuditIntegrityVerificationResult.Failed(
 16112                    AuditIntegrityVerificationCategory.MissingAnchor,
 16113                    "integrity.anchor-not-applicable",
 16114                    "An expected previous link hash cannot apply to a chain that starts at the genesis link.",
 16115                    firstLink);
 116        }
 117
 4118        if (string.IsNullOrWhiteSpace(expectedPreviousLinkHash))
 119        {
 1120            return AuditIntegrityVerificationResult.Failed(
 1121                AuditIntegrityVerificationCategory.MissingAnchor,
 1122                "integrity.expected-previous-hash-required",
 1123                "A partial chain that does not start at the genesis link must supply the expected previous link hash.",
 1124                firstLink);
 125        }
 126
 3127        expectedPreviousHash = expectedPreviousLinkHash.Trim();
 3128        return null;
 129    }
 130
 131    /// <summary>
 132    /// Rejects a chain that verifies internally but does not reach the tip the caller expected.
 133    /// </summary>
 134    private static AuditIntegrityVerificationResult? VerifyTip(
 135        AuditIntegrityLink tip,
 136        string? expectedTipLinkHash,
 137        long? expectedTipSequence)
 138    {
 5139        return !string.IsNullOrWhiteSpace(expectedTipLinkHash)
 5140            && !string.Equals(tip.LinkHash, expectedTipLinkHash.Trim(), StringComparison.Ordinal)
 5141            ? AuditIntegrityVerificationResult.Failed(
 5142                AuditIntegrityVerificationCategory.TruncatedChain,
 5143                "integrity.chain-truncated",
 5144                "The final link does not match the expected tip link hash.",
 5145                tip,
 5146                new Dictionary<string, string>(StringComparer.Ordinal)
 5147                {
 5148                    ["expected_tip_link_hash"] = expectedTipLinkHash.Trim(),
 5149                    ["actual_tip_link_hash"] = tip.LinkHash
 5150                })
 5151            : expectedTipSequence.HasValue && tip.Sequence != expectedTipSequence.Value
 5152            ? AuditIntegrityVerificationResult.Failed(
 5153                AuditIntegrityVerificationCategory.TruncatedChain,
 5154                "integrity.chain-truncated",
 5155                "The final link does not match the expected tip sequence.",
 5156                tip,
 5157                new Dictionary<string, string>(StringComparer.Ordinal)
 5158                {
 5159                    ["expected_tip_sequence"] = expectedTipSequence.Value.ToString(System.Globalization.CultureInfo.Inva
 5160                    ["actual_tip_sequence"] = tip.Sequence.ToString(System.Globalization.CultureInfo.InvariantCulture)
 5161                })
 5162            : null;
 163    }
 164
 165    private static AuditIntegrityVerificationResult? VerifyLink(
 166        AuditIntegrityLink link,
 167        string expectedChainId,
 168        long expectedSequence,
 169        string expectedPreviousHash,
 170        HashSet<long> observedSequences,
 171        bool requireGenesis)
 172    {
 39173        if (!string.Equals(link.HashAlgorithm, CanonicalPayloadOptions.DefaultHashAlgorithm, StringComparison.Ordinal))
 174        {
 1175            return AuditIntegrityVerificationResult.Failed(
 1176                AuditIntegrityVerificationCategory.UnsupportedAlgorithm,
 1177                "integrity.hash-algorithm-unsupported",
 1178                "The integrity link uses an unsupported hash algorithm.",
 1179                link);
 180        }
 181
 38182        if (!string.Equals(link.ChainId, expectedChainId, StringComparison.Ordinal))
 183        {
 1184            return AuditIntegrityVerificationResult.Failed(
 1185                AuditIntegrityVerificationCategory.WrongChain,
 1186                "integrity.chain-id-mismatch",
 1187                "The integrity link belongs to a different chain.",
 1188                link);
 189        }
 190
 37191        if (observedSequences.Contains(link.Sequence))
 192        {
 4193            return AuditIntegrityVerificationResult.Failed(
 4194                AuditIntegrityVerificationCategory.ForkedChain,
 4195                "integrity.sequence-duplicate",
 4196                "Multiple links claim the same chain sequence.",
 4197                link);
 198        }
 199
 33200        if (link.Sequence != expectedSequence)
 201        {
 3202            AuditIntegrityVerificationCategory category = link.Sequence > expectedSequence
 3203                ? AuditIntegrityVerificationCategory.MissingRecord
 3204                : AuditIntegrityVerificationCategory.ReorderedRecord;
 205
 3206            return AuditIntegrityVerificationResult.Failed(
 3207                category,
 3208                category is AuditIntegrityVerificationCategory.MissingRecord
 3209                    ? "integrity.sequence-missing"
 3210                    : "integrity.sequence-reordered",
 3211                "The integrity link sequence is not continuous in the supplied order.",
 3212                link,
 3213                new Dictionary<string, string>(StringComparer.Ordinal)
 3214                {
 3215                    ["expected_sequence"] = expectedSequence.ToString(System.Globalization.CultureInfo.InvariantCulture)
 3216                    ["actual_sequence"] = link.Sequence.ToString(System.Globalization.CultureInfo.InvariantCulture)
 3217                });
 218        }
 219
 30220        if (requireGenesis && link.Sequence == 1 && link.PreviousLinkHash.Length != 0)
 221        {
 1222            return AuditIntegrityVerificationResult.Failed(
 1223                AuditIntegrityVerificationCategory.HashMismatch,
 1224                "integrity.genesis-previous-hash-present",
 1225                "The genesis link must not point to a previous link hash.",
 1226                link);
 227        }
 228
 29229        if (link.Sequence > 1 && link.PreviousLinkHash.Length == 0)
 230        {
 1231            return AuditIntegrityVerificationResult.Failed(
 1232                AuditIntegrityVerificationCategory.HashMismatch,
 1233                "integrity.previous-link-hash-missing",
 1234                "A link after the genesis link must point to a previous link hash.",
 1235                link);
 236        }
 237
 28238        if (!string.Equals(link.PreviousLinkHash, expectedPreviousHash, StringComparison.Ordinal))
 239        {
 2240            return AuditIntegrityVerificationResult.Failed(
 2241                AuditIntegrityVerificationCategory.HashMismatch,
 2242                "integrity.previous-link-hash-mismatch",
 2243                "The integrity link does not point to the previous link hash.",
 2244                link,
 2245                new Dictionary<string, string>(StringComparer.Ordinal)
 2246                {
 2247                    ["expected_previous_hash"] = expectedPreviousHash,
 2248                    ["actual_previous_hash"] = link.PreviousLinkHash
 2249                });
 250        }
 251
 26252        string expectedLinkHash = link.ComputeExpectedLinkHash();
 26253        return !string.Equals(link.LinkHash, expectedLinkHash, StringComparison.Ordinal)
 26254            ? AuditIntegrityVerificationResult.Failed(
 26255                AuditIntegrityVerificationCategory.ModifiedRecord,
 26256                "integrity.link-hash-mismatch",
 26257                "The integrity link hash no longer matches its canonical fields.",
 26258                link,
 26259                new Dictionary<string, string>(StringComparer.Ordinal)
 26260                {
 26261                    ["expected_link_hash"] = expectedLinkHash,
 26262                    ["actual_link_hash"] = link.LinkHash
 26263                })
 26264            : null;
 265    }
 266}