From a61865daa0a86456c7e81b6c40e47c4715a00dcf Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 26 Jun 2026 12:28:48 -0400 Subject: [PATCH] feat(deploy): fetch options + per-deployment token helper --- .../Types/Deployment/DeploymentFetchToken.cs | 22 +++++++++ .../CommunicationOptions.cs | 13 +++++ .../DeploymentFetchTokenTests.cs | 47 +++++++++++++++++++ 3 files changed, 82 insertions(+) create mode 100644 src/ZB.MOM.WW.ScadaBridge.Commons/Types/Deployment/DeploymentFetchToken.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/DeploymentFetchTokenTests.cs diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Deployment/DeploymentFetchToken.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Deployment/DeploymentFetchToken.cs new file mode 100644 index 00000000..489c3947 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Deployment/DeploymentFetchToken.cs @@ -0,0 +1,22 @@ +using System.Security.Cryptography; +using System.Text; + +namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Deployment; + +/// +/// Generates and compares the single-purpose, short-TTL token that authorizes a +/// site's HTTP fetch of one deployment's flattened config. URL-safe; compared in +/// constant time to avoid timing oracles. +/// +public static class DeploymentFetchToken +{ + /// Generates a URL-safe random token (256 bits of entropy). + public static string Generate() => + Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)) + .Replace('+', '-').Replace('/', '_').TrimEnd('='); + + /// Constant-time string comparison (no early-out on first mismatch). + public static bool ConstantTimeEquals(string a, string b) => + CryptographicOperations.FixedTimeEquals( + Encoding.UTF8.GetBytes(a), Encoding.UTF8.GetBytes(b)); +} diff --git a/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationOptions.cs b/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationOptions.cs index 5c4790ac..78c6cc41 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationOptions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationOptions.cs @@ -58,4 +58,17 @@ public class CommunicationOptions /// Akka.Remote transport failure detection threshold. public TimeSpan TransportFailureThreshold { get; set; } = TimeSpan.FromSeconds(15); + + /// + /// Base URL (Traefik/LB) the SITE uses to fetch deploy configs from central, + /// e.g. "https://central.example:9000". Carried in RefreshDeploymentCommand so + /// sites need no new standing config. Empty disables notify-and-fetch fallback. + /// + public string CentralFetchBaseUrl { get; set; } = ""; + + /// + /// How long a staged PendingDeployment (and its fetch token) stays valid. Must + /// comfortably cover both site nodes' fetches within one deploy window. + /// + public TimeSpan PendingDeploymentTtl { get; set; } = TimeSpan.FromMinutes(5); } diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/DeploymentFetchTokenTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/DeploymentFetchTokenTests.cs new file mode 100644 index 00000000..8f9b3adf --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/DeploymentFetchTokenTests.cs @@ -0,0 +1,47 @@ +using ZB.MOM.WW.ScadaBridge.Commons.Types.Deployment; + +namespace ZB.MOM.WW.ScadaBridge.Commons.Tests; + +public class DeploymentFetchTokenTests +{ + [Fact] + public void Generate_ReturnsUrlSafeTokenOfAdequateLength() + { + var token = DeploymentFetchToken.Generate(); + + Assert.True(token.Length >= 32, $"Expected length >= 32 but got {token.Length}"); + Assert.DoesNotContain('+', token); + Assert.DoesNotContain('/', token); + } + + [Fact] + public void Generate_TwoCallsReturnDifferentValues() + { + var a = DeploymentFetchToken.Generate(); + var b = DeploymentFetchToken.Generate(); + + Assert.NotEqual(a, b); + } + + [Fact] + public void ConstantTimeEquals_SameToken_ReturnsTrue() + { + var token = DeploymentFetchToken.Generate(); + + Assert.True(DeploymentFetchToken.ConstantTimeEquals(token, token)); + } + + [Fact] + public void ConstantTimeEquals_TokenWithSuffix_ReturnsFalse() + { + var token = DeploymentFetchToken.Generate(); + + Assert.False(DeploymentFetchToken.ConstantTimeEquals(token, token + "x")); + } + + [Fact] + public void ConstantTimeEquals_BothEmpty_ReturnsTrue() + { + Assert.True(DeploymentFetchToken.ConstantTimeEquals("", "")); + } +}