feat(artifacts): secrets and missing-secret markers in the artifact revision hash
This commit is contained in:
@@ -9,4 +9,12 @@ public record DeployArtifactsCommand(
|
|||||||
IReadOnlyList<DataConnectionArtifact>? DataConnections,
|
IReadOnlyList<DataConnectionArtifact>? DataConnections,
|
||||||
IReadOnlyList<SmtpConfigurationArtifact>? SmtpConfigurations,
|
IReadOnlyList<SmtpConfigurationArtifact>? SmtpConfigurations,
|
||||||
DateTimeOffset Timestamp,
|
DateTimeOffset Timestamp,
|
||||||
IReadOnlyList<SecretArtifact>? Secrets = null);
|
IReadOnlyList<SecretArtifact>? Secrets = null,
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <see cref="DeploymentManager.ArtifactRevisionHashService"/>'s
|
||||||
|
/// hash so a site stays flagged out of date until the reference is fixed. Never mapped
|
||||||
|
/// to the wire (<c>SiteCommandDtoMapper</c> does not project it) and never sent to a site.
|
||||||
|
/// </summary>
|
||||||
|
IReadOnlyList<string>? MissingSecrets = null);
|
||||||
|
|||||||
@@ -34,6 +34,16 @@ namespace ZB.MOM.WW.ScadaBridge.DeploymentManager;
|
|||||||
/// in would only add a permanently-null branch).
|
/// in would only add a permanently-null branch).
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
|
/// INCLUDED: the command's <c>Secrets</c> — 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. <c>MissingSecrets</c> —
|
||||||
|
/// central-only names of secret references that failed to resolve — is folded in as
|
||||||
|
/// distinguishable marker entries (<c>Missing = true</c>, 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.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
/// A no-op re-save of a data connection does NOT change the hash. Connection
|
/// 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
|
/// configuration is encrypted at rest through an EF value converter, so the
|
||||||
/// ciphertext changes on every save, but the entity — and hence
|
/// ciphertext changes on every save, but the entity — and hence
|
||||||
@@ -111,6 +121,7 @@ public sealed class ArtifactRevisionHashService
|
|||||||
})
|
})
|
||||||
.ToList()
|
.ToList()
|
||||||
: null,
|
: null,
|
||||||
|
Secrets = BuildHashableSecrets(command),
|
||||||
SharedScripts = command.SharedScripts is { Count: > 0 }
|
SharedScripts = command.SharedScripts is { Count: > 0 }
|
||||||
? command.SharedScripts
|
? command.SharedScripts
|
||||||
.OrderBy(s => s.Name, StringComparer.Ordinal)
|
.OrderBy(s => s.Name, StringComparer.Ordinal)
|
||||||
@@ -130,6 +141,42 @@ public sealed class ArtifactRevisionHashService
|
|||||||
return $"sha256:{Convert.ToHexStringLower(hashBytes)}";
|
return $"sha256:{Convert.ToHexStringLower(hashBytes)}";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Projects the command's <c>Secrets</c> and <c>MissingSecrets</c> into a single
|
||||||
|
/// name-sorted list of <see cref="HashableSecret"/> entries. A present secret carries a
|
||||||
|
/// content digest over its ciphertext material (never the plaintext, which this service
|
||||||
|
/// never sees) plus its <c>KekId</c> and <c>Revision</c>; a missing reference carries only
|
||||||
|
/// its name and <c>Missing = true</c>, 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.
|
||||||
|
/// </summary>
|
||||||
|
private static List<HashableSecret>? BuildHashableSecrets(DeployArtifactsCommand command)
|
||||||
|
{
|
||||||
|
var list = new List<HashableSecret>();
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Re-serializes an external system's JSON-encoded method list with its methods sorted
|
/// 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
|
/// by name (ordinal), so the repository's row order cannot leak into the hash. Anything
|
||||||
@@ -211,6 +258,11 @@ public sealed class ArtifactRevisionHashService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public List<HashableExternalSystem>? ExternalSystems { get; init; }
|
public List<HashableExternalSystem>? ExternalSystems { get; init; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public List<HashableSecret>? Secrets { get; init; }
|
||||||
|
/// <summary>
|
||||||
/// The system-wide shared scripts, sorted by name; null when there are none.
|
/// The system-wide shared scripts, sorted by name; null when there are none.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public List<HashableSharedScript>? SharedScripts { get; init; }
|
public List<HashableSharedScript>? SharedScripts { get; init; }
|
||||||
@@ -299,6 +351,34 @@ public sealed class ArtifactRevisionHashService
|
|||||||
public int TimeoutSeconds { get; init; }
|
public int TimeoutSeconds { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed record HashableSecret
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// SHA-256 digest over the secret's ciphertext, nonce, tag, wrapped DEK, wrap-nonce,
|
||||||
|
/// and wrap-tag — never the plaintext. Null for a <see cref="Missing"/> marker entry.
|
||||||
|
/// </summary>
|
||||||
|
public string? ContentDigest { get; init; }
|
||||||
|
/// <summary>
|
||||||
|
/// The key-encryption-key identifier the secret is wrapped under. Null for a
|
||||||
|
/// <see cref="Missing"/> marker entry.
|
||||||
|
/// </summary>
|
||||||
|
public string? KekId { get; init; }
|
||||||
|
/// <summary>
|
||||||
|
/// True when this entry is a <c>MissingSecrets</c> 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.
|
||||||
|
/// </summary>
|
||||||
|
public bool? Missing { get; init; }
|
||||||
|
/// <summary>
|
||||||
|
/// The secret's name — the key data connection configuration references it by.
|
||||||
|
/// </summary>
|
||||||
|
public required string Name { get; init; }
|
||||||
|
/// <summary>
|
||||||
|
/// The secret's revision number. Null for a <see cref="Missing"/> marker entry.
|
||||||
|
/// </summary>
|
||||||
|
public long? Revision { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
private sealed record HashableSharedScript
|
private sealed record HashableSharedScript
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
+42
@@ -46,6 +46,48 @@ public class ArtifactRevisionHashServiceTests
|
|||||||
SmtpConfigurations: null,
|
SmtpConfigurations: null,
|
||||||
timestamp ?? DateTimeOffset.UtcNow);
|
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<SecretArtifact>? secrets = null,
|
||||||
|
IReadOnlyList<string>? 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]
|
[Fact]
|
||||||
public void ComputeHash_PreSecretsGoldenValue_IsUnchanged()
|
public void ComputeHash_PreSecretsGoldenValue_IsUnchanged()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user