diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Artifacts/DeployArtifactsCommand.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Artifacts/DeployArtifactsCommand.cs index 31296e1c..484ca2ec 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Artifacts/DeployArtifactsCommand.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Artifacts/DeployArtifactsCommand.cs @@ -9,4 +9,12 @@ public record DeployArtifactsCommand( IReadOnlyList? DataConnections, IReadOnlyList? SmtpConfigurations, DateTimeOffset Timestamp, - IReadOnlyList? Secrets = null); + IReadOnlyList? Secrets = null, + /// + /// Names of secrets referenced by this site's data connections that could not be + /// resolved at central (missing or tombstoned). CENTRAL-ONLY: used solely to fold a + /// distinguishable marker into 's + /// hash so a site stays flagged out of date until the reference is fixed. Never mapped + /// to the wire (SiteCommandDtoMapper does not project it) and never sent to a site. + /// + IReadOnlyList? MissingSecrets = null); diff --git a/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/ArtifactRevisionHashService.cs b/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/ArtifactRevisionHashService.cs index a22d31a2..e0fa7e4b 100644 --- a/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/ArtifactRevisionHashService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/ArtifactRevisionHashService.cs @@ -34,6 +34,16 @@ namespace ZB.MOM.WW.ScadaBridge.DeploymentManager; /// 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 @@ -111,6 +121,7 @@ public sealed class ArtifactRevisionHashService }) .ToList() : null, + Secrets = BuildHashableSecrets(command), SharedScripts = command.SharedScripts is { Count: > 0 } ? command.SharedScripts .OrderBy(s => s.Name, StringComparer.Ordinal) @@ -130,6 +141,42 @@ public sealed class ArtifactRevisionHashService 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 @@ -211,6 +258,11 @@ public sealed class ArtifactRevisionHashService /// 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; } @@ -299,6 +351,34 @@ public sealed class ArtifactRevisionHashService 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 { /// diff --git a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/ArtifactRevisionHashServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/ArtifactRevisionHashServiceTests.cs index 8b4e7a13..0e21140d 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/ArtifactRevisionHashServiceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/ArtifactRevisionHashServiceTests.cs @@ -46,6 +46,48 @@ public class ArtifactRevisionHashServiceTests SmtpConfigurations: null, timestamp ?? DateTimeOffset.UtcNow); + private static SecretArtifact Secret(string name, long rev) => + new(name, "text/plain", + Ciphertext: [1, 2, 3], + Nonce: [4, 5], + Tag: [6, 7], + WrappedDek: [8, 9], + WrapNonce: [10, 11], + WrapTag: [12, 13], + KekId: "kek-1", + Revision: rev, + CreatedUtc: DateTimeOffset.Parse("2026-01-01T00:00:00Z"), + UpdatedUtc: DateTimeOffset.Parse("2026-01-02T00:00:00Z")); + + private static DeployArtifactsCommand Cmd( + IReadOnlyList? secrets = null, + IReadOnlyList? missing = null) => + Command() with { Secrets = secrets, MissingSecrets = missing }; + + [Fact] + public void SecretChanges_MoveTheHash_ButOrderDoesNot() + { + var svc = new ArtifactRevisionHashService(); + var a = Secret("A", rev: 1); + var b = Secret("B", rev: 1); + var baseline = svc.ComputeHash(Cmd(secrets: [a, b])); + + Assert.Equal(baseline, svc.ComputeHash(Cmd(secrets: [b, a]))); + Assert.NotEqual(baseline, svc.ComputeHash(Cmd(secrets: [a with { Revision = 2 }, b]))); + Assert.NotEqual(baseline, svc.ComputeHash(Cmd(secrets: [a with { WrappedDek = [42] }, b]))); + Assert.NotEqual(baseline, svc.ComputeHash(Cmd(secrets: [a with { KekId = "kek-B" }, b]))); + } + + [Fact] + public void MissingSecret_HashesDifferentlyFromPresent() + { + var svc = new ArtifactRevisionHashService(); + + Assert.NotEqual( + svc.ComputeHash(Cmd(secrets: [Secret("A", 1)])), + svc.ComputeHash(Cmd(missing: ["A"]))); + } + [Fact] public void ComputeHash_PreSecretsGoldenValue_IsUnchanged() {