From cdac0d0755f29a7dd420e91e331bf3efde45f180 Mon Sep 17 00:00:00 2001 From: keegreil Date: Tue, 18 Aug 2026 23:44:20 -0400 Subject: [PATCH 01/10] security: harden post-critical appliance candidate --- .github/workflows/build.yml | 27 +- Dockerfile | 6 +- boot.tests/BitcoinRpcRecoveryPlannerTests.cs | 6 +- boot.tests/BitcoinZmqNotificationTests.cs | 20 +- boot.tests/PoolConfigValidatorTests.cs | 24 +- boot.tests/SecurityHardeningTests.cs | 94 ++++++ boot.tests/ShareAttributionTests.cs | 76 +++-- boot.tests/boot.tests.csproj | 2 + boot.tests/packages.lock.json | 296 ++++++++++++++++++ .../Controllers/BootNetworkController.cs | 136 +++++--- .../BitcoinRpcReconciliationService.cs | 13 +- .../HostedServices/BootPeerSyncService.cs | 31 +- boot_portal/HostedServices/DatumServer.cs | 11 + boot_portal/Pages/Index.cshtml | 12 +- boot_portal/Program.cs | 69 +++- .../Services/BitcoinNotificationHealth.cs | 5 +- .../Services/BitcoinRpcRecoveryPlanner.cs | 16 +- .../Services/BootPeerUdpRelayService.cs | 197 ------------ .../Services/BootProtocolStateService.cs | 143 +++------ boot_portal/Services/BootShareVerifier.cs | 22 +- .../Services/PeerBundleFetchLimiter.cs | 52 +++ boot_portal/Utils/PoolConfigValidator.cs | 25 +- boot_portal/boot_portal.csproj | 6 + boot_portal/packages.lock.json | 97 ++++++ docker/boot_portal_config.sample.json | 5 +- .../boot_portal_config.testnet4.sample.json | 5 +- docs/security-hardening-rt-043-049.md | 48 +++ update_server.sh | 29 +- 28 files changed, 1053 insertions(+), 420 deletions(-) create mode 100644 boot.tests/SecurityHardeningTests.cs create mode 100644 boot.tests/packages.lock.json create mode 100644 boot_portal/Services/PeerBundleFetchLimiter.cs create mode 100644 boot_portal/packages.lock.json create mode 100644 docs/security-hardening-rt-043-049.md diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4130c3b..4fc419b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -29,13 +29,13 @@ jobs: dotnet-version: 9.0.x - name: Restore app - run: dotnet restore boot_portal/boot_portal.csproj + run: dotnet restore boot_portal/boot_portal.csproj --locked-mode - name: Build app run: dotnet build boot_portal/boot_portal.csproj --configuration Release --no-restore - name: Restore tests - run: dotnet restore boot.tests/boot.tests.csproj + run: dotnet restore boot.tests/boot.tests.csproj --locked-mode - name: Build tests run: dotnet build boot.tests/boot.tests.csproj --configuration Release --no-restore @@ -43,6 +43,29 @@ jobs: - name: Run tests run: dotnet test boot.tests/boot.tests.csproj --configuration Release --no-build + - name: Dependency advisory report + run: dotnet list boot_portal/boot_portal.csproj package --vulnerable --include-transitive + + - name: Filesystem vulnerability scan + uses: aquasecurity/trivy-action@0.30.0 + with: + scan-type: fs + scan-ref: . + severity: CRITICAL,HIGH + ignore-unfixed: true + exit-code: '1' + + secrets: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: gitleaks/gitleaks-action@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + docker: runs-on: ubuntu-latest needs: dotnet diff --git a/Dockerfile b/Dockerfile index 39d15bf..c9e0798 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,13 +1,13 @@ -FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build +FROM mcr.microsoft.com/dotnet/sdk:9.0@sha256:35048e3a81e6a07c316e7bbbd80d80d2ba705fe5f23a8ed42b6638c8f4c20d30 AS build WORKDIR /src COPY boot_portal/boot_portal.csproj boot_portal/ -RUN dotnet restore boot_portal/boot_portal.csproj +RUN dotnet restore boot_portal/boot_portal.csproj --locked-mode COPY . . RUN dotnet publish boot_portal/boot_portal.csproj -c Release -o /app/publish /p:UseAppHost=false -FROM mcr.microsoft.com/dotnet/aspnet:9.0-bookworm-slim AS runtime +FROM mcr.microsoft.com/dotnet/aspnet:9.0-bookworm-slim@sha256:4e376dd15bbc8437d4892367ab0ea06a3ac9fea482d10f92f3c493fe1a2219ad AS runtime RUN apt-get update \ && apt-get install -y --no-install-recommends libsodium23 ca-certificates curl \ && rm -rf /var/lib/apt/lists/* \ diff --git a/boot.tests/BitcoinRpcRecoveryPlannerTests.cs b/boot.tests/BitcoinRpcRecoveryPlannerTests.cs index 4c19912..19cd633 100644 --- a/boot.tests/BitcoinRpcRecoveryPlannerTests.cs +++ b/boot.tests/BitcoinRpcRecoveryPlannerTests.cs @@ -42,12 +42,12 @@ public void SameHeightReplacementUsesReorganizationPath() 100, "new-block-100"); - CollectionAssert.AreEqual(new long[] { 100 }, plan.Heights.ToArray()); + CollectionAssert.AreEqual(new long[] { 99, 100 }, plan.Heights.ToArray()); Assert.IsTrue(plan.Reorganization); } [TestMethod] - public void LowerRpcHeightPausesUntilReplacementChainCatchesUp() + public void LowerRpcHeightReplaysReplacementTipAndParent() { BitcoinRpcRecoveryPlan plan = BitcoinRpcRecoveryPlanner.Build( 101, @@ -55,7 +55,7 @@ public void LowerRpcHeightPausesUntilReplacementChainCatchesUp() 100, "replacement-block-100"); - Assert.AreEqual(0, plan.Heights.Count); + CollectionAssert.AreEqual(new long[] { 99, 100 }, plan.Heights.ToArray()); Assert.IsTrue(plan.Reorganization); } diff --git a/boot.tests/BitcoinZmqNotificationTests.cs b/boot.tests/BitcoinZmqNotificationTests.cs index ab832d3..d3f6aa0 100644 --- a/boot.tests/BitcoinZmqNotificationTests.cs +++ b/boot.tests/BitcoinZmqNotificationTests.cs @@ -128,7 +128,7 @@ public void AttachedNodeRequiresSynchronizedRpcAndRedactsErrors() BitcoinRpcUrl = "http://bitcoin:8332", BitcoinRpcLagGraceSeconds = 1 }); - DateTime nowUtc = DateTime.UtcNow; + DateTime nowUtc = DateTime.UtcNow.AddSeconds(2); health.RecordRpcFailure( "failed http://alice:secret@bitcoin:8332/wallet/private?token=secret", nowUtc.AddSeconds(-2)); @@ -171,4 +171,22 @@ public void AttachedNodeReportsDuplicateZmqPublishersWithoutFailingReadiness() Assert.IsTrue(snapshot.DegradedReason.Contains("duplicate", StringComparison.OrdinalIgnoreCase)); Assert.AreEqual(2, snapshot.ZmqTopics.Single(topic => topic.Topic == "hashblock").PublisherCount); } + + [TestMethod] + public void RepeatedRpcFailuresCannotRenewMiningSafetyGracePeriod() + { + var health = new BitcoinNotificationHealth(new PoolConfig + { + BitcoinNotificationMode = BitcoinNotificationModes.AttachedNode, + BitcoinRpcUrl = "http://bitcoin:8332", + BitcoinRpcLagGraceSeconds = 1 + }); + DateTime afterGrace = DateTime.UtcNow.AddSeconds(2); + + health.RecordRpcFailure("warmup", afterGrace.AddMilliseconds(-500)); + health.RecordRpcFailure("still warming up", afterGrace); + + Assert.IsFalse(health.IsMiningSafe(afterGrace, out string reason)); + StringAssert.Contains(reason, "unreachable"); + } } diff --git a/boot.tests/PoolConfigValidatorTests.cs b/boot.tests/PoolConfigValidatorTests.cs index 83be840..1709894 100644 --- a/boot.tests/PoolConfigValidatorTests.cs +++ b/boot.tests/PoolConfigValidatorTests.cs @@ -6,13 +6,16 @@ namespace boot.tests; [TestClass] public sealed class PoolConfigValidatorTests { + private const string MainnetPayoutAddress = "bc1qd9m04z95mglaxd9e9accmhyjdlmkfmzjprkq4p"; + [TestMethod] - public void DefaultCoinbaseTagIsGridPool() + public void DefaultConfigurationRequiresExplicitPayoutAddress() { var config = new PoolConfig(); Assert.AreEqual("Grid Pool", config.CoinbaseTag); - CollectionAssert.AreEqual(Array.Empty(), PoolConfigValidator.Validate(config)); + Assert.IsTrue(PoolConfigValidator.Validate(config).Any(error => + error.Contains("pool_payout_script", StringComparison.OrdinalIgnoreCase))); } [TestMethod] @@ -120,7 +123,9 @@ public void EmptyCoinbaseTagIsAllowed() { var config = new PoolConfig { - CoinbaseTag = string.Empty + CoinbaseTag = string.Empty, + PoolPayoutScript = MainnetPayoutAddress, + EnableAdminApi = false }; CollectionAssert.AreEqual(Array.Empty(), PoolConfigValidator.Validate(config)); @@ -196,7 +201,8 @@ public void Testnet4AcceptsTestnetPayoutAddressAndRejectsMainnetAddress() var validConfig = new PoolConfig { BitcoinNetwork = BitcoinScript.Testnet4, - PoolPayoutScript = testnetAddress + PoolPayoutScript = testnetAddress, + EnableAdminApi = false }; CollectionAssert.AreEqual(Array.Empty(), PoolConfigValidator.Validate(validConfig)); @@ -240,7 +246,9 @@ public void SovereignModeIsAcceptedForInstallerNodes() { NodeMode = "sovereign", PublicBaseUrl = "http://edge-node.local:5000", - DatumPublicHost = "edge-node.local" + DatumPublicHost = "edge-node.local", + PoolPayoutScript = MainnetPayoutAddress, + EnableAdminApi = false }; CollectionAssert.AreEqual(Array.Empty(), PoolConfigValidator.Validate(config)); @@ -291,7 +299,8 @@ public void ProductionAcceptsExplicitPublicEndpoints() PublicBaseUrl = "https://use1.gridlabs.science", DatumPublicHost = "datum-use1.gridlabs.science", EnableAdminApi = false, - TestingRoundResetMode = "none" + TestingRoundResetMode = "none", + PoolPayoutScript = MainnetPayoutAddress }; CollectionAssert.AreEqual(Array.Empty(), PoolConfigValidator.Validate(config)); @@ -325,7 +334,8 @@ public void ProductionAcceptsStrongAdminKeyWhenAdminApiIsEnabled() DatumPublicHost = "datum-use1.gridlabs.science", EnableAdminApi = true, AdminApiKey = new string('a', 32), - TestingRoundResetMode = "none" + TestingRoundResetMode = "none", + PoolPayoutScript = MainnetPayoutAddress }; CollectionAssert.AreEqual(Array.Empty(), PoolConfigValidator.Validate(config)); diff --git a/boot.tests/SecurityHardeningTests.cs b/boot.tests/SecurityHardeningTests.cs new file mode 100644 index 0000000..d09a50d --- /dev/null +++ b/boot.tests/SecurityHardeningTests.cs @@ -0,0 +1,94 @@ +using System.Net; +using boot_portal.Controllers; +using boot_portal.Services; +using boot_portal.Utils; + +namespace boot.tests; + +[TestClass] +public sealed class SecurityHardeningTests +{ + private const string MainnetPayoutAddress = "bc1qd9m04z95mglaxd9e9accmhyjdlmkfmzjprkq4p"; + + [TestMethod] + public void DatumResourceBoundsAreValidated() + { + var config = ValidConfig(); + config.DatumMaxConnections = 0; + config.DatumReadTimeoutSeconds = 0; + + List errors = PoolConfigValidator.Validate(config); + + Assert.IsTrue(errors.Any(error => error.Contains("datum_max_connections", StringComparison.OrdinalIgnoreCase))); + Assert.IsTrue(errors.Any(error => error.Contains("datum_read_timeout_seconds", StringComparison.OrdinalIgnoreCase))); + } + + [TestMethod] + public void AdminApiRequiresStrongKeyInSovereignMode() + { + var config = ValidConfig(); + config.EnableAdminApi = true; + config.AdminApiKey = "change-this-admin-key"; + + Assert.IsTrue(PoolConfigValidator.Validate(config).Any(error => + error.Contains("admin_api_key", StringComparison.OrdinalIgnoreCase))); + } + + [TestMethod] + public void StoredMinerLabelIsBoundedAndMarkupFree() + { + string normalized = BootShareVerifier.NormalizeUsernameForStorage( + "worker/../../" + new string('x', 256), + MainnetPayoutAddress); + + Assert.IsTrue(normalized.Length <= 128); + Assert.IsFalse(normalized.Contains('<')); + Assert.IsFalse(normalized.Contains('>')); + Assert.IsFalse(normalized.Contains('/')); + Assert.IsTrue(normalized.All(value => char.IsAsciiLetterOrDigit(value) || value is '.' or '_' or '-' or ':')); + } + + [DataTestMethod] + [DataRow("127.0.0.1")] + [DataRow("10.1.2.3")] + [DataRow("172.16.0.1")] + [DataRow("192.168.1.1")] + [DataRow("169.254.1.1")] + [DataRow("100.64.0.1")] + [DataRow("224.0.0.1")] + [DataRow("::1")] + [DataRow("fe80::1")] + [DataRow("fc00::1")] + public void ReachabilityProbeRejectsNonPublicDestinations(string value) + { + Assert.IsTrue(BootNetworkController.IsNonPublicAddress(IPAddress.Parse(value))); + } + + [TestMethod] + public void ReachabilityProbeAcceptsPublicUnicastDestination() + { + Assert.IsFalse(BootNetworkController.IsNonPublicAddress(IPAddress.Parse("1.1.1.1"))); + } + + [TestMethod] + public void PeerBundleFetchLimiterBoundsChangingStateIdsPerPeer() + { + var limiter = new PeerBundleFetchLimiter(2, TimeSpan.FromMinutes(1)); + DateTime start = new(2026, 8, 19, 0, 0, 0, DateTimeKind.Utc); + + Assert.IsTrue(limiter.TryAcquire("https://peer.example", start)); + Assert.IsTrue(limiter.TryAcquire("https://peer.example", start.AddSeconds(1))); + Assert.IsFalse(limiter.TryAcquire("https://peer.example", start.AddSeconds(2))); + Assert.IsTrue(limiter.TryAcquire("https://other.example", start.AddSeconds(2))); + Assert.IsTrue(limiter.TryAcquire("https://peer.example", start.AddMinutes(1).AddSeconds(1))); + } + + private static PoolConfig ValidConfig() + { + return new PoolConfig + { + PoolPayoutScript = MainnetPayoutAddress, + EnableAdminApi = false + }; + } +} diff --git a/boot.tests/ShareAttributionTests.cs b/boot.tests/ShareAttributionTests.cs index 56d8c9a..b985572 100644 --- a/boot.tests/ShareAttributionTests.cs +++ b/boot.tests/ShareAttributionTests.cs @@ -588,23 +588,23 @@ public async Task ProoflessBootstrapCannotInstallRemoteWinnersOrPaidLineageAsync [TestMethod] public async Task ProofBackedRemoteStateCannotRewriteUnverifiedPaidLineageAsync() { - using var remoteHarness = TestHarness.Create(currentTipBlockHash: OlderTipBlockHash); - ShareRecordingResult shareResult = await remoteHarness.StateService.SubmitShareAsync( - new RecordedShareSubmission - { - MinerAddress = AlternateAddress, - Username = string.Empty, - HeaderHex = SampleHeaderHex, - CoinbaseHex = SampleCoinbaseHex, - MerklePath = SampleMerklePath.ToList(), - PrevBlockHash = SamplePrevBlockHash, - Source = "datum" - }, - "datum-block"); - Assert.IsTrue(shareResult.Accepted, shareResult.RejectionReason); + byte[] header = Convert.FromHexString(SampleHeaderHex); + var proofSet = new List(); + for (uint nonce = 0; nonce < 16; nonce++) + { + BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(76, 4), nonce); + proofSet.Add(CreateValidatedProof( + Convert.ToHexString(header).ToLowerInvariant(), + SamplePrevBlockHash, + "seed-current")); + } + + using var remoteHarness = TestHarness.Create( + currentTipBlockHash: SamplePrevBlockHash, + onDeckProofs: proofSet); RoundRotationResult rotation = await remoteHarness.StateService.RotateToNextRoundAsync( - SamplePrevBlockHash, + OlderTipBlockHash, "test-rotation", manual: false, blockHeight: 945001, @@ -613,11 +613,11 @@ public async Task ProofBackedRemoteStateCannotRewriteUnverifiedPaidLineageAsync( Assert.IsTrue(remoteBundle.WorkSetProofs.Count > 0); using var localHarness = TestHarness.Create( - currentTipBlockHash: SamplePrevBlockHash, + currentTipBlockHash: OlderTipBlockHash, currentRoundNumber: remoteBundle.CurrentRoundNumber); bool adopted = await localHarness.StateService.TryAdoptCurrentStateAsync( remoteBundle, - SamplePrevBlockHash, + OlderTipBlockHash, 945001, "https://peer.example"); @@ -628,7 +628,7 @@ public async Task ProofBackedRemoteStateCannotRewriteUnverifiedPaidLineageAsync( } [TestMethod] - public async Task DatumShareOnFreshParentIsAcceptedAndLearnsParentWithoutTipAdvanceAsync() + public async Task DatumShareCannotTeachNodeAnUnvalidatedFreshParentAsync() { using var harness = TestHarness.Create(currentTipBlockHash: OlderTipBlockHash); @@ -643,17 +643,17 @@ public async Task DatumShareOnFreshParentIsAcceptedAndLearnsParentWithoutTipAdva Source = "datum" }, "datum-block"); - Assert.IsTrue(result.Accepted, result.RejectionReason); - Assert.AreEqual(SamplePrevBlockHash, result.AcceptedProof?.PrevBlockHash); + Assert.IsFalse(result.Accepted); + StringAssert.Contains(result.RejectionReason, "outside the active Bitcoin tip"); BootNetworkStatusDto status = harness.StateService.GetNetworkStatus(); Assert.AreEqual(OlderTipBlockHash, status.CurrentTipBlockHash); - Assert.AreEqual(1, harness.StateService.GetOnDeckList().Count); + Assert.AreEqual(0, harness.StateService.GetOnDeckList().Count); Thread.Sleep(1200); PoolState persisted = JsonSerializer.Deserialize(File.ReadAllText(harness.StatePath))!; CollectionAssert.Contains(persisted.AcceptedParentBlockHashes, OlderTipBlockHash); - CollectionAssert.Contains(persisted.AcceptedParentBlockHashes, SamplePrevBlockHash); + CollectionAssert.DoesNotContain(persisted.AcceptedParentBlockHashes, SamplePrevBlockHash); } [TestMethod] @@ -1360,7 +1360,7 @@ await harness.StateService.ObserveChainTipAsync( "test"); Assert.IsFalse(result.Accepted); - Assert.AreEqual("New previous-parent proof rejected after the local snapshot boundary.", result.RejectionReason); + StringAssert.Contains(result.RejectionReason, "outside the active Bitcoin tip"); Assert.AreEqual(0, harness.StateService.GetNetworkStatus().WorkSetCount); } @@ -1528,6 +1528,36 @@ public async Task V22OneBlockReorgCreatesIsolatedReplacementFamilyAndRestoresLin Assert.AreEqual(2, replacement.CurrentRoundNumber); } + [TestMethod] + public async Task V22TwoBlockReorgRollsBackRemovedFamiliesBeforeReplacementAsync() + { + BootShareProof proof = CreateValidatedProof(SampleHeaderHex, SamplePrevBlockHash, "seed-current"); + BootPayoutSnapshotContext predecessor = CreateSnapshotContext("seed-current", SampleExpectedWinners); + using var harness = TestHarness.Create( + sharedWinnerSlotCount: 1, + onDeckProofs: [proof], + snapshotContexts: [predecessor]); + string removedFirst = "0000000000000000000000000000000000000000000000000000000000a00601"; + string removedSecond = "0000000000000000000000000000000000000000000000000000000000a00602"; + string replacementFirst = "0000000000000000000000000000000000000000000000000000000000b00601"; + + await harness.StateService.ObserveChainTipAsync(removedFirst, "local-bitcoin", 945001); + BootNetworkStatusDto removed = await harness.StateService.ObserveChainTipAsync( + removedSecond, + "local-bitcoin", + 945002); + BootNetworkStatusDto replacement = await harness.StateService.ObserveChainTipAsync( + replacementFirst, + "local-bitcoin-reorg", + 945001); + + Assert.AreNotEqual(removed.ActiveSnapshotFamilyId, replacement.ActiveSnapshotFamilyId); + Assert.AreEqual(replacementFirst, replacement.CurrentTipBlockHash); + Assert.AreEqual(945001L, replacement.CurrentTipBlockHeight); + Assert.AreEqual(1, replacement.WorkSetCount); + Assert.AreEqual(2, replacement.CurrentRoundNumber); + } + [TestMethod] public async Task V22ReserveOnlySiblingAdditionDoesNotChangeActivePayoutSnapshotAsync() { diff --git a/boot.tests/boot.tests.csproj b/boot.tests/boot.tests.csproj index d9f7cc2..b350538 100644 --- a/boot.tests/boot.tests.csproj +++ b/boot.tests/boot.tests.csproj @@ -5,6 +5,8 @@ latest enable enable + true + true