using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts; namespace ZB.MOM.WW.ScadaBridge.DeploymentManager; /// /// Produces a deterministic SHA-256 hash of the per-site system-wide artifact set /// carried by a (shared scripts, external /// systems, database connections, and the site's data connections). The hash is /// stamped on Site.ArtifactRevisionHash after a successful artifact deploy /// and compared against a freshly-computed hash of the current definitions to tell /// whether a site's deployed artifacts are out of date. /// /// DETERMINISM CONTRACT: System.Text.Json serializes properties in CLR declaration /// order and does NOT sort them. Stable output therefore relies on the private /// Hashable* records below declaring their properties in alphabetical /// (ordinal) order — a contributor adding a property out of order would silently /// change every site's artifact hash and flag the whole fleet out of date. The /// ordering is guarded by /// ArtifactRevisionHashServiceTests.HashableRecords_PropertiesDeclaredAlphabetically, /// mirroring RevisionHashService. Every artifact list is explicitly sorted by /// Name (ordinal) before hashing, so repository row order never leaks into the /// hash, and an external system's MethodDefinitionsJson — itself a serialized /// collection — is re-canonicalised with its methods sorted by name. /// /// /// EXCLUDED FROM THE HASH: the command's DeploymentId and Timestamp /// (both volatile per deploy — two deploys of identical definitions must hash the /// same), and NotificationLists/SmtpConfigurations (notification /// delivery is central-only; central never populates those fields, so folding them /// in would only add a permanently-null branch). /// /// /// INCLUDED: the command's Secrets — projected as a content digest (SHA-256 of /// ciphertext + nonce + tag + wrapped-DEK + wrap-nonce + wrap-tag) alongside the name, /// revision, and KekId, so plaintext never enters the hash input and re-wrapping under /// a new KEK with unchanged content still moves the hash. MissingSecrets — /// central-only names of secret references that failed to resolve — is folded in as /// distinguishable marker entries (Missing = true, no digest) so a site stays /// flagged out of date while a reference is broken, and a resolved secret hashes /// differently from the same name unresolved. /// /// /// A no-op re-save of a data connection does NOT change the hash. Connection /// configuration is encrypted at rest through an EF value converter, so the /// ciphertext changes on every save, but the entity — and hence /// and this hash — only ever sees the /// plaintext. Re-encryption at rest is therefore invisible here, which is exactly /// what keeps the "out of date" indicator quiet for a save that changed nothing. /// /// public sealed class ArtifactRevisionHashService { private static readonly JsonSerializerOptions CanonicalJsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, WriteIndented = false }; /// /// Computes the deterministic SHA-256 hash of the artifact set carried by the given /// per-site deploy command. Identical artifact definitions always produce an identical /// hash, regardless of deployment id, timestamp, or the order the artifacts were /// returned by their repositories. /// /// The per-site artifact deployment command to hash. /// /// The canonical revision hash, encoded as sha256: followed by the lower-case /// hex digest (the same encoding as RevisionHashService). /// public string ComputeHash(DeployArtifactsCommand command) { ArgumentNullException.ThrowIfNull(command); // Each list is projected null-when-empty and the serializer omits null // properties, so an absent artifact set and an empty one hash identically. var hashInput = new HashableArtifactSet { DataConnections = command.DataConnections is { Count: > 0 } ? command.DataConnections .OrderBy(c => c.Name, StringComparer.Ordinal) .Select(c => new HashableDataConnection { BackupConfigurationJson = c.BackupConfigurationJson, FailoverRetryCount = c.FailoverRetryCount, Name = c.Name, PrimaryConfigurationJson = c.PrimaryConfigurationJson, Protocol = c.Protocol }) .ToList() : null, DatabaseConnections = command.DatabaseConnections is { Count: > 0 } ? command.DatabaseConnections .OrderBy(d => d.Name, StringComparer.Ordinal) .Select(d => new HashableDatabaseConnection { ConnectionString = d.ConnectionString, MaxRetries = d.MaxRetries, Name = d.Name, RetryDelayTicks = d.RetryDelay.Ticks }) .ToList() : null, ExternalSystems = command.ExternalSystems is { Count: > 0 } ? command.ExternalSystems .OrderBy(e => e.Name, StringComparer.Ordinal) .Select(e => new HashableExternalSystem { AuthConfiguration = e.AuthConfiguration, AuthType = e.AuthType, EndpointUrl = e.EndpointUrl, MaxRetries = e.MaxRetries, MethodDefinitionsJson = CanonicalizeMethodDefinitions(e.MethodDefinitionsJson), Name = e.Name, RetryDelayTicks = e.RetryDelay.Ticks, TimeoutSeconds = e.TimeoutSeconds }) .ToList() : null, Secrets = BuildHashableSecrets(command), SharedScripts = command.SharedScripts is { Count: > 0 } ? command.SharedScripts .OrderBy(s => s.Name, StringComparer.Ordinal) .Select(s => new HashableSharedScript { Code = s.Code, Name = s.Name, ParameterDefinitions = s.ParameterDefinitions, ReturnDefinition = s.ReturnDefinition }) .ToList() : null }; var json = JsonSerializer.Serialize(hashInput, CanonicalJsonOptions); var hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(json)); return $"sha256:{Convert.ToHexStringLower(hashBytes)}"; } /// /// Projects the command's Secrets and MissingSecrets into a single /// name-sorted list of entries. A present secret carries a /// content digest over its ciphertext material (never the plaintext, which this service /// never sees) plus its KekId and Revision; a missing reference carries only /// its name and Missing = true, so the same name present vs. missing always hashes /// differently. Returns null when there is nothing to hash, matching every other /// null-when-empty artifact list here. /// private static List? BuildHashableSecrets(DeployArtifactsCommand command) { var list = new List(); foreach (var s in command.Secrets ?? []) { list.Add(new HashableSecret { ContentDigest = Convert.ToHexStringLower(SHA256.HashData( [.. s.Ciphertext, .. s.Nonce, .. s.Tag, .. s.WrappedDek, .. s.WrapNonce, .. s.WrapTag])), KekId = s.KekId, Missing = null, Name = s.Name, Revision = s.Revision }); } foreach (var m in command.MissingSecrets ?? []) { list.Add(new HashableSecret { Missing = true, Name = m }); } return list.Count == 0 ? null : list.OrderBy(x => x.Name, StringComparer.Ordinal).ThenBy(x => x.Missing == true).ToList(); } /// /// Re-serializes an external system's JSON-encoded method list with its methods sorted /// by name (ordinal), so the repository's row order cannot leak into the hash. Anything /// that is not a parseable JSON array is returned verbatim — the hash still changes when /// the payload does, which is the only guarantee this method owes. /// /// The name property is matched CASE-INSENSITIVELY. Today's producer /// (ArtifactDeploymentService.FetchGlobalArtifactsAsync) serializes an anonymous /// type, so the key is PascalCase Name — but a future camelCase naming policy there /// would otherwise silently turn this sort into a no-op and let row order move the hash /// again, with nothing failing. /// /// private static string? CanonicalizeMethodDefinitions(string? methodDefinitionsJson) { if (string.IsNullOrWhiteSpace(methodDefinitionsJson)) return methodDefinitionsJson; try { using var document = JsonDocument.Parse(methodDefinitionsJson); if (document.RootElement.ValueKind != JsonValueKind.Array) return methodDefinitionsJson; var ordered = document.RootElement .EnumerateArray() .Select(element => element.Clone()) .OrderBy(MethodSortKey, StringComparer.Ordinal) .ToList(); return JsonSerializer.Serialize(ordered, CanonicalJsonOptions); } catch (JsonException) { return methodDefinitionsJson; } } /// /// Sort key for one serialized external-system method: its name property, matched /// case-insensitively so the key survives a naming-policy change at the producer. /// Elements that are not objects, or carry no string name, sort first under an empty key — /// their relative order is then whatever the payload gave, which is the best a nameless /// element allows. /// private static string MethodSortKey(JsonElement element) { if (element.ValueKind != JsonValueKind.Object) return string.Empty; foreach (var property in element.EnumerateObject()) { if (string.Equals(property.Name, "Name", StringComparison.OrdinalIgnoreCase)) { return property.Value.ValueKind == JsonValueKind.String ? property.Value.GetString() ?? string.Empty : string.Empty; } } return string.Empty; } // Internal types for deterministic serialization. Properties MUST be declared in // alphabetical (ordinal) order — System.Text.Json emits them in declaration order // and does not sort. See the DETERMINISM CONTRACT note on the class summary. private sealed record HashableArtifactSet { /// /// The site's data connections, sorted by name; null when the site has none. /// public List? DataConnections { get; init; } /// /// The system-wide database connections, sorted by name; null when there are none. /// public List? DatabaseConnections { get; init; } /// /// The system-wide external systems, sorted by name; null when there are none. /// public List? ExternalSystems { get; init; } /// /// The secrets referenced by this site (present, as a content digest, or missing, as /// a marker entry), sorted by name then by missing-last; null when there are none. /// public List? Secrets { get; init; } /// /// The system-wide shared scripts, sorted by name; null when there are none. /// public List? SharedScripts { get; init; } } private sealed record HashableDataConnection { /// /// Backup connection configuration JSON (plaintext in memory), if any. /// public string? BackupConfigurationJson { get; init; } /// /// Number of failover retries before giving up. /// public int FailoverRetryCount { get; init; } /// /// The connection name — the key scripts and bindings address it by. /// public string Name { get; init; } = string.Empty; /// /// Primary connection configuration JSON (plaintext in memory), if any. /// public string? PrimaryConfigurationJson { get; init; } /// /// Protocol name (e.g. "OpcUa"). /// public string Protocol { get; init; } = string.Empty; } private sealed record HashableDatabaseConnection { /// /// The database connection string shipped to the site. /// public string ConnectionString { get; init; } = string.Empty; /// /// Store-and-Forward retry budget for this connection's cached writes (0 = unset). /// public int MaxRetries { get; init; } /// /// The database connection name. /// public string Name { get; init; } = string.Empty; /// /// Store-and-Forward retry interval in ticks (0 = unset). Carried as ticks rather /// than a so the serialized form cannot vary by format. /// public long RetryDelayTicks { get; init; } } private sealed record HashableExternalSystem { /// /// JSON-serialized authentication configuration for . /// public string? AuthConfiguration { get; init; } /// /// Authentication type identifier (e.g. ApiKey, BasicAuth). /// public string AuthType { get; init; } = string.Empty; /// /// Base URL of the system's HTTP endpoint. /// public string EndpointUrl { get; init; } = string.Empty; /// /// Store-and-Forward retry budget for this system's cached calls (0 = unset). /// public int MaxRetries { get; init; } /// /// The system's method list, re-canonicalised with the methods sorted by name; /// null when the system defines none. /// public string? MethodDefinitionsJson { get; init; } /// /// Unique system name — the key scripts call it by. /// public string Name { get; init; } = string.Empty; /// /// Store-and-Forward retry interval in ticks (0 = unset). Carried as ticks rather /// than a so the serialized form cannot vary by format. /// public long RetryDelayTicks { get; init; } /// /// Per-system HTTP round-trip timeout in seconds (0 = unset). /// public int TimeoutSeconds { get; init; } } private sealed record HashableSecret { /// /// SHA-256 digest over the secret's ciphertext, nonce, tag, wrapped DEK, wrap-nonce, /// and wrap-tag — never the plaintext. Null for a marker entry. /// public string? ContentDigest { get; init; } /// /// The key-encryption-key identifier the secret is wrapped under. Null for a /// marker entry. /// public string? KekId { get; init; } /// /// True when this entry is a MissingSecrets marker (a referenced secret that /// failed to resolve at central) rather than a resolved secret; null when resolved, /// so the same name present vs. missing always serializes — and hashes — differently. /// public bool? Missing { get; init; } /// /// The secret's name — the key data connection configuration references it by. /// public required string Name { get; init; } /// /// The secret's revision number. Null for a marker entry. /// public long? Revision { get; init; } } private sealed record HashableSharedScript { /// /// The shared script's source code. /// public string Code { get; init; } = string.Empty; /// /// The shared script name — the key other scripts call it by. /// public string Name { get; init; } = string.Empty; /// /// JSON representation of the script's parameter definitions. /// public string? ParameterDefinitions { get; init; } /// /// JSON representation of the script's return type definition. /// public string? ReturnDefinition { get; init; } } }