< Summary

Information
Class: AsiBackbone.Core.Signing.CanonicalPayloadBuilder
Assembly: AsiBackbone.Core
File(s): /home/runner/work/AsiBackbone/AsiBackbone/src/AsiBackbone.Core/Signing/CanonicalPayloadBuilder.cs
Line coverage
100%
Covered lines: 217
Uncovered lines: 0
Coverable lines: 217
Total lines: 367
Line coverage: 100%
Branch coverage
97%
Covered branches: 37
Total branches: 38
Branch coverage: 97.3%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

File(s)

/home/runner/work/AsiBackbone/AsiBackbone/src/AsiBackbone.Core/Signing/CanonicalPayloadBuilder.cs

#LineLine coverage
 1using System.Globalization;
 2using AsiBackbone.Core.Audit;
 3using AsiBackbone.Core.CapabilityGrants;
 4using AsiBackbone.Core.Emissions;
 5using AsiBackbone.Core.Outbox;
 6using AsiBackbone.Core.Serialization;
 7
 8namespace AsiBackbone.Core.Signing;
 9
 10/// <summary>
 11/// Builds deterministic, provider-neutral signing payloads for AsiBackbone governance artifacts.
 12/// </summary>
 13public static class CanonicalPayloadBuilder
 14{
 15    /// <summary>
 16    /// Builds a canonical payload for decision receipt.
 17    /// </summary>
 18    public static CanonicalPayload ForDecisionReceipt(IDecisionReceipt receipt, CanonicalPayloadOptions? options = null)
 19    {
 5520        ArgumentNullException.ThrowIfNull(receipt);
 5521        CanonicalPayloadOptions effectiveOptions = options ?? CanonicalPayloadOptions.Default;
 5522        string decisionReceiptId = GetDecisionReceiptId(receipt);
 23
 5524        return CanonicalPayload.Create(
 5525            CanonicalArtifactTypes.DecisionReceipt,
 5526            decisionReceiptId,
 5527            receipt.SchemaVersion,
 5528            effectiveOptions.CanonicalizationVersion,
 5529            effectiveOptions.HashAlgorithm,
 5530            BuildDecisionReceiptContent(receipt, effectiveOptions, decisionReceiptId));
 31    }
 32
 33    /// <summary>
 34    /// Builds a canonical payload for a persistence-ready audit ledger record.
 35    /// </summary>
 36    public static CanonicalPayload ForAuditLedgerRecord(AuditLedgerRecord record, CanonicalPayloadOptions? options = nul
 37    {
 2638        ArgumentNullException.ThrowIfNull(record);
 2639        CanonicalPayloadOptions effectiveOptions = options ?? CanonicalPayloadOptions.Default;
 40
 2641        SortedDictionary<string, object?> content = BuildDecisionReceiptContent(record, effectiveOptions, record.Decisio
 2642        content["acknowledgmentId"] = record.AcknowledgmentId;
 2643        content["capabilityGrantId"] = record.CapabilityTokenId;
 2644        content["handshakeId"] = record.HandshakeId;
 2645        content["previousRecordHash"] = record.PreviousRecordHash;
 2646        content["recordedUtc"] = FormatUtc(record.RecordedUtc);
 2647        content["recordId"] = record.RecordId;
 48
 2649        return CanonicalPayload.Create(
 2650            CanonicalArtifactTypes.AuditLedgerRecord,
 2651            record.RecordId,
 2652            record.SchemaVersion,
 2653            effectiveOptions.CanonicalizationVersion,
 2654            effectiveOptions.HashAlgorithm,
 2655            content);
 56    }
 57
 58    /// <summary>
 59    /// Builds a canonical payload for an decision receipt lifecycle event.
 60    /// </summary>
 61    public static CanonicalPayload ForDecisionReceiptLifecycleEvent(DecisionReceiptLifecycleEvent lifecycleEvent, Canoni
 62    {
 2263        ArgumentNullException.ThrowIfNull(lifecycleEvent);
 2264        CanonicalPayloadOptions effectiveOptions = options ?? CanonicalPayloadOptions.Default;
 65
 2266        SortedDictionary<string, object?> content = new(StringComparer.Ordinal)
 2267        {
 2268            ["auditResidueId"] = lifecycleEvent.DecisionReceiptId,
 2269            ["correlationId"] = lifecycleEvent.CorrelationId,
 2270            ["eventId"] = lifecycleEvent.EventId,
 2271            ["metadata"] = FilterMetadata(lifecycleEvent.Metadata, effectiveOptions),
 2272            ["occurredUtc"] = FormatUtc(lifecycleEvent.OccurredUtc),
 2273            ["operationName"] = lifecycleEvent.OperationName,
 2274            ["outcome"] = lifecycleEvent.Outcome,
 2275            ["stage"] = CanonicalEnumWireNames.ForLifecycleStage(lifecycleEvent.Stage),
 2276            ["stageSequence"] = lifecycleEvent.StageSequence,
 2277            ["traceId"] = lifecycleEvent.TraceId
 2278        };
 79
 2280        return CanonicalPayload.Create(
 2281            CanonicalArtifactTypes.DecisionReceiptLifecycleEvent,
 2282            lifecycleEvent.EventId,
 2283            GovernanceSchemaVersions.StableArtifactsV1,
 2284            effectiveOptions.CanonicalizationVersion,
 2285            effectiveOptions.HashAlgorithm,
 2286            content);
 87    }
 88
 89    /// <summary>
 90    /// Builds a canonical payload for a governance emission envelope.
 91    /// </summary>
 92    public static CanonicalPayload ForGovernanceEmissionEnvelope(GovernanceEmissionEnvelope envelope, CanonicalPayloadOp
 93    {
 4494        ArgumentNullException.ThrowIfNull(envelope);
 4495        CanonicalPayloadOptions effectiveOptions = options ?? CanonicalPayloadOptions.Default;
 96
 4497        return CanonicalPayload.Create(
 4498            CanonicalArtifactTypes.GovernanceEmissionEnvelope,
 4499            envelope.EnvelopeId,
 44100            envelope.SchemaVersion,
 44101            effectiveOptions.CanonicalizationVersion,
 44102            effectiveOptions.HashAlgorithm,
 44103            BuildGovernanceEmissionEnvelopeContent(envelope, effectiveOptions));
 104    }
 105
 106    /// <summary>
 107    /// Builds a canonical payload for a durable outbox entry.
 108    /// </summary>
 109    public static CanonicalPayload ForGovernanceOutboxEntry(GovernanceOutboxEntry entry, CanonicalPayloadOptions? option
 110    {
 16111        ArgumentNullException.ThrowIfNull(entry);
 16112        CanonicalPayloadOptions effectiveOptions = options ?? CanonicalPayloadOptions.Default;
 113
 16114        SortedDictionary<string, object?> content = new(StringComparer.Ordinal)
 16115        {
 16116            ["createdUtc"] = FormatUtc(entry.CreatedUtc),
 16117            ["deadLetterReason"] = entry.DeadLetterReason,
 16118            ["envelope"] = BuildGovernanceEmissionEnvelopeContent(entry.Envelope, effectiveOptions),
 16119            ["lastError"] = BuildGovernanceEmissionErrorContent(entry.LastError),
 16120            ["maxRetryCount"] = entry.MaxRetryCount,
 16121            ["metadata"] = FilterMetadata(entry.Metadata, effectiveOptions),
 16122            ["nextRetryUtc"] = FormatUtc(entry.NextRetryUtc),
 16123            ["outboxEntryId"] = entry.OutboxEntryId,
 16124            ["providerName"] = entry.ProviderName,
 16125            ["providerRecordId"] = entry.ProviderRecordId,
 16126            ["retryCount"] = entry.RetryCount,
 16127            ["status"] = CanonicalEnumWireNames.ForEmissionStatus(entry.Status),
 16128            ["updatedUtc"] = FormatUtc(entry.UpdatedUtc)
 16129        };
 130
 16131        return CanonicalPayload.Create(
 16132            CanonicalArtifactTypes.GovernanceOutboxEntry,
 16133            entry.OutboxEntryId,
 16134            entry.Envelope.SchemaVersion,
 16135            effectiveOptions.CanonicalizationVersion,
 16136            effectiveOptions.HashAlgorithm,
 16137            content);
 138    }
 139
 140    /// <summary>
 141    /// Builds a canonical payload for a capability grant grant.
 142    /// </summary>
 143    /// <remarks>
 144    /// <para>
 145    /// Every field the grant carries is included, so the resulting hash binds the whole grant rather than a subset of
 146    /// it. A payload that omits a field leaves that field outside the proof while
 147    /// <see cref="CapabilityGrantValidator" /> still enforces it, which lets a modified value pass
 148    /// validation against a signature computed before the change. Scopes are normalized to a sorted, de-duplicated,
 149    /// ordinal set, so two grants that differ only in scope ordering produce the same hash.
 150    /// </para>
 151    /// <para>
 152    /// Metadata is the one exception, and deliberately so: it is filtered through
 153    /// <see cref="CanonicalPayloadOptions.AllowsMetadataKey" />, whose allow-list is empty by default. With default
 154    /// options no grant metadata reaches the proof at all, which keeps unbounded and potentially sensitive host data
 155    /// out of hashed payloads. A host that puts security-relevant data in grant metadata has no binding for it until
 156    /// that key is added to the allow-list.
 157    /// </para>
 158    /// </remarks>
 159    /// <param name="grant">The capability grant grant to canonicalize.</param>
 160    /// <param name="options">Canonicalization options, including the metadata allow-list.</param>
 161    /// <returns>A deterministic canonical payload for the grant.</returns>
 162    public static CanonicalPayload ForCapabilityGrant(CapabilityGrant grant, CanonicalPayloadOptions? options = null)
 163    {
 148164        ArgumentNullException.ThrowIfNull(grant);
 147165        CanonicalPayloadOptions effectiveOptions = options ?? CanonicalPayloadOptions.Default;
 166
 147167        SortedDictionary<string, object?> content = new(StringComparer.Ordinal)
 147168        {
 147169            ["acknowledgmentId"] = grant.AcknowledgmentId,
 147170            ["audience"] = grant.Audience,
 147171            ["expiresUtc"] = FormatUtc(grant.ExpiresUtc),
 147172            ["gatewayBinding"] = grant.GatewayBinding,
 147173            ["handshakeId"] = grant.HandshakeId,
 147174            ["issuedUtc"] = FormatUtc(grant.IssuedUtc),
 147175            ["issuer"] = grant.Issuer,
 147176            ["metadata"] = FilterMetadata(grant.Metadata, effectiveOptions),
 147177            ["notBeforeUtc"] = FormatUtc(grant.NotBeforeUtc),
 147178            ["operationName"] = grant.OperationName,
 147179            ["policyHash"] = grant.PolicyHash,
 147180            ["policyVersion"] = grant.PolicyVersion,
 147181            ["resourceBinding"] = grant.ResourceBinding,
 147182            ["scopes"] = NormalizeStringSet(grant.Scopes),
 147183            ["subjectId"] = grant.SubjectId,
 147184            ["tokenId"] = grant.TokenId
 147185        };
 186
 187        // A use limit supplied only at the validation call site is unsigned local policy that the issuer never
 188        // authorized. Binding it requires the grant to record the schema version that carries it, so grants signed unde
 189        // the earlier version continue to hash exactly as they did and keep verifying.
 147190        if (!string.Equals(grant.SchemaVersion, GovernanceSchemaVersions.StableArtifactsV1, StringComparison.Ordinal))
 191        {
 4192            content["maxUseCount"] = grant.MaxUseCount;
 193        }
 194
 147195        return CanonicalPayload.Create(
 147196            CanonicalArtifactTypes.CapabilityGrant,
 147197            grant.TokenId,
 147198            grant.SchemaVersion,
 147199            effectiveOptions.CanonicalizationVersion,
 147200            effectiveOptions.HashAlgorithm,
 147201            content);
 202    }
 203
 204    private static SortedDictionary<string, object?> BuildDecisionReceiptContent(
 205        IDecisionReceipt receipt,
 206        CanonicalPayloadOptions options,
 207        string decisionReceiptId)
 208    {
 81209        return new SortedDictionary<string, object?>(StringComparer.Ordinal)
 81210        {
 81211            ["actorDisplayName"] = receipt.ActorDisplayName,
 81212            ["actorId"] = receipt.ActorId,
 81213            ["actorType"] = CanonicalEnumWireNames.ForActorType(receipt.ActorType),
 81214            ["auditResidueId"] = decisionReceiptId,
 81215            ["constraintCount"] = receipt.ConstraintCount,
 81216            ["constraintSetHash"] = receipt.ConstraintSetHash,
 81217            ["correlationId"] = receipt.CorrelationId,
 81218            ["decisionLatencyMs"] = receipt.DecisionLatencyMs,
 81219            ["decisionStage"] = receipt.DecisionStage,
 81220            ["emitterProvider"] = receipt.EmitterProvider,
 81221            ["emitterStatus"] = receipt.EmitterStatus,
 81222            ["eventId"] = receipt.EventId,
 81223            ["gatewayExecutionId"] = receipt.GatewayExecutionId,
 81224            ["metadata"] = FilterMetadata(receipt.Metadata, options),
 81225            ["occurredUtc"] = FormatUtc(receipt.OccurredUtc),
 81226            ["operationName"] = receipt.OperationName,
 81227            ["organizationHash"] = receipt.OrganizationHash,
 81228            ["outboxSequence"] = receipt.OutboxSequence,
 81229            ["outcome"] = receipt.Outcome,
 81230            ["parentSpanId"] = receipt.ParentSpanId,
 81231            ["policyHash"] = receipt.PolicyHash,
 81232            ["policyScope"] = receipt.PolicyScope,
 81233            ["policyVersion"] = receipt.PolicyVersion,
 81234            ["reasonCodes"] = NormalizeStringSet(receipt.ReasonCodes),
 81235            ["riskScore"] = receipt.RiskScore,
 81236            ["schemaVersion"] = receipt.SchemaVersion,
 81237            ["spanId"] = receipt.SpanId,
 81238            ["tenantHash"] = receipt.TenantHash,
 81239            ["traceId"] = receipt.TraceId
 81240        };
 241    }
 242
 243    private static SortedDictionary<string, object?> BuildGovernanceEmissionEnvelopeContent(GovernanceEmissionEnvelope e
 244    {
 60245        return new SortedDictionary<string, object?>(StringComparer.Ordinal)
 60246        {
 60247            ["actorId"] = envelope.ActorId,
 60248            ["auditResidueId"] = envelope.DecisionReceiptId,
 60249            ["correlationId"] = envelope.CorrelationId,
 60250            ["createdUtc"] = FormatUtc(envelope.CreatedUtc),
 60251            ["decisionStage"] = envelope.DecisionStage,
 60252            ["emitterProvider"] = envelope.EmitterProvider,
 60253            ["emitterStatus"] = envelope.EmitterStatus,
 60254            ["envelopeId"] = envelope.EnvelopeId,
 60255            ["eventId"] = envelope.EventId,
 60256            ["eventType"] = CanonicalEnumWireNames.ForEmissionEventType(envelope.EventType),
 60257            ["gatewayExecutionId"] = envelope.GatewayExecutionId,
 60258            ["lifecycleStage"] = envelope.LifecycleStage.HasValue
 60259                ? CanonicalEnumWireNames.ForLifecycleStage(envelope.LifecycleStage.Value)
 60260                : null,
 60261            ["lifecycleStageSequence"] = envelope.LifecycleStageSequence,
 60262            ["metadata"] = FilterMetadata(envelope.Metadata, options),
 60263            ["occurredUtc"] = FormatUtc(envelope.OccurredUtc),
 60264            ["operationName"] = envelope.OperationName,
 60265            ["outboxSequence"] = envelope.OutboxSequence,
 60266            ["outcome"] = envelope.Outcome,
 60267            ["parentSpanId"] = envelope.ParentSpanId,
 60268            ["payload"] = BuildGovernanceEmissionPayloadContent(envelope.Payload, options),
 60269            ["policyHash"] = envelope.PolicyHash,
 60270            ["policyVersion"] = envelope.PolicyVersion,
 60271            ["schemaVersion"] = envelope.SchemaVersion,
 60272            ["spanId"] = envelope.SpanId,
 60273            ["traceId"] = envelope.TraceId
 60274        };
 275    }
 276
 277    private static SortedDictionary<string, object?>? BuildGovernanceEmissionPayloadContent(GovernanceEmissionPayload? p
 278    {
 60279        return payload is null
 60280            ? null
 60281            : new SortedDictionary<string, object?>(StringComparer.Ordinal)
 60282            {
 60283                ["contentHash"] = payload.ContentHash,
 60284                ["contentType"] = payload.ContentType,
 60285                ["metadata"] = FilterMetadata(payload.Metadata, options),
 60286                ["payloadType"] = payload.PayloadType,
 60287                ["schemaVersion"] = payload.SchemaVersion,
 60288                ["sizeBytes"] = payload.SizeBytes
 60289            };
 290    }
 291
 292    private static SortedDictionary<string, object?>? BuildGovernanceEmissionErrorContent(GovernanceEmissionError? error
 293    {
 16294        return error is null
 16295            ? null
 16296            : new SortedDictionary<string, object?>(StringComparer.Ordinal)
 16297            {
 16298                ["code"] = error.Code,
 16299                ["isRetryable"] = error.IsRetryable,
 16300                ["message"] = error.Message,
 16301                ["providerErrorCode"] = error.ProviderErrorCode,
 16302                ["providerName"] = error.ProviderName
 16303            };
 304    }
 305
 306    // Shared by every canonical payload builder in this assembly so all artifacts apply the metadata filtering and
 307    // normalization rules published for asibackbone.canonical-json.v1.
 308    internal static SortedDictionary<string, object?> FilterMetadata(IReadOnlyDictionary<string, string>? metadata, Cano
 309    {
 351310        SortedDictionary<string, object?> filteredMetadata = new(StringComparer.Ordinal);
 311
 351312        if (metadata is null || metadata.Count == 0)
 313        {
 296314            return filteredMetadata;
 315        }
 316
 301317        foreach (KeyValuePair<string, string> item in metadata)
 318        {
 96319            if (!options.AllowsMetadataKey(item.Key))
 320            {
 321                continue;
 322            }
 323
 54324            string normalizedKey = item.Key.Trim();
 325
 326            // Trimming keys before hashing makes "k" and " k" the same key. Overwriting silently made the hashed payloa
 327            // depend on enumeration order, so the same logical metadata could hash two ways and one key's value could
 328            // displace another's inside the signed payload.
 54329            if (filteredMetadata.ContainsKey(normalizedKey))
 330            {
 1331                throw new ArgumentException(
 1332                    $"Canonical payload metadata contains keys that collide after trimming: '{normalizedKey}'. Normalize
 1333                    nameof(metadata));
 334            }
 335
 53336            filteredMetadata[normalizedKey] = item.Value?.Trim() ?? string.Empty;
 337        }
 338
 54339        return filteredMetadata;
 340    }
 341
 342    private static string[] NormalizeStringSet(IEnumerable<string> values)
 343    {
 227344        return [.. values
 227345            .Where(value => !string.IsNullOrWhiteSpace(value))
 227346            .Select(value => value.Trim())
 227347            .Distinct(StringComparer.Ordinal)
 227348            .OrderBy(value => value, StringComparer.Ordinal)];
 349    }
 350
 351    private static string? FormatUtc(DateTimeOffset? timestamp)
 352    {
 163353        return timestamp.HasValue ? FormatUtc(timestamp.Value) : null;
 354    }
 355
 356    private static string FormatUtc(DateTimeOffset timestamp)
 357    {
 582358        return timestamp.ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss.fffffff'Z'", CultureInfo.InvariantCulture);
 359    }
 360
 361    private static string GetDecisionReceiptId(IDecisionReceipt receipt)
 362    {
 55363        return string.IsNullOrWhiteSpace(receipt.DecisionReceiptId)
 55364            ? receipt.EventId
 55365            : receipt.DecisionReceiptId;
 366    }
 367}

Methods/Properties

ForDecisionReceipt(AsiBackbone.Core.Audit.IDecisionReceipt,AsiBackbone.Core.Signing.CanonicalPayloadOptions)
ForAuditLedgerRecord(AsiBackbone.Core.Audit.AuditLedgerRecord,AsiBackbone.Core.Signing.CanonicalPayloadOptions)
ForDecisionReceiptLifecycleEvent(AsiBackbone.Core.Audit.DecisionReceiptLifecycleEvent,AsiBackbone.Core.Signing.CanonicalPayloadOptions)
ForGovernanceEmissionEnvelope(AsiBackbone.Core.Emissions.GovernanceEmissionEnvelope,AsiBackbone.Core.Signing.CanonicalPayloadOptions)
ForGovernanceOutboxEntry(AsiBackbone.Core.Outbox.GovernanceOutboxEntry,AsiBackbone.Core.Signing.CanonicalPayloadOptions)
ForCapabilityGrant(AsiBackbone.Core.CapabilityGrants.CapabilityGrant,AsiBackbone.Core.Signing.CanonicalPayloadOptions)
BuildDecisionReceiptContent(AsiBackbone.Core.Audit.IDecisionReceipt,AsiBackbone.Core.Signing.CanonicalPayloadOptions,System.String)
BuildGovernanceEmissionEnvelopeContent(AsiBackbone.Core.Emissions.GovernanceEmissionEnvelope,AsiBackbone.Core.Signing.CanonicalPayloadOptions)
BuildGovernanceEmissionPayloadContent(AsiBackbone.Core.Emissions.GovernanceEmissionPayload,AsiBackbone.Core.Signing.CanonicalPayloadOptions)
BuildGovernanceEmissionErrorContent(AsiBackbone.Core.Emissions.GovernanceEmissionError)
FilterMetadata(System.Collections.Generic.IReadOnlyDictionary`2<System.String,System.String>,AsiBackbone.Core.Signing.CanonicalPayloadOptions)
NormalizeStringSet(System.Collections.Generic.IEnumerable`1<System.String>)
FormatUtc(System.Nullable`1<System.DateTimeOffset>)
FormatUtc(System.DateTimeOffset)
GetDecisionReceiptId(AsiBackbone.Core.Audit.IDecisionReceipt)