Compare commits

...

24 Commits

Author SHA1 Message Date
Sami Ahmed ff0eeaac89 net: harden v5.9.22 networking changes — strict parser, tests, debug logs
Three pure helper functions extracted from ThreadHTTPSeedFetch2 into
netbase.{h,cpp} so the HTTPS seed-list code path can be unit-tested
without the SSL/Tor network stack:

  int DechunkTransferEncoding(const std::string& body, std::string& out)
  std::vector<std::string> ParseSeedListBody(const std::string& body)
  bool IsValidSocksNegotiationTimeout(int nMs)

DechunkTransferEncoding is now strict (was lenient):

  - Hex validation: every byte of the chunk-size line is checked with
    isxdigit() before strtoull. Old code passed a raw strtoul() result
    which silently accepted leading '+', '-', and whitespace.
  - strtoull + errno + size_t bounds check replaces the silent
    'if (pos+chunkSize > body.size()) chunkSize = body.size()-pos'
    clamp. The old behavior would mask truncated network reads.
  - Empty size lines, '+5' / '-5' / ' 5', and unsigned overflow all
    return DECHUNK_INVALID_HEX (or DECHUNK_OVERSIZE_CHUNK for the
    bounds case) instead of being treated as 0/last-chunk.
  - Missing CRLF after chunk data returns DECHUNK_MISSING_DATA_CRLF
    rather than being read as the next chunk-size line.
  - Body without a '0\r\n' last-chunk terminator returns
    DECHUNK_NO_CHUNK_TERMINATOR instead of silently being accepted.
  - Chunk extensions ('5;foo=bar') are still preserved — the ';'
    delimiter is stripped from the size line, not from the framing.

ParseSeedListBody is a 1:1 extraction of the old loop. Same behavior
on every input. Trims inline '#' comments, splits on whitespace /
comma / semicolon, normalizes CR-only line endings.

IsValidSocksNegotiationTimeout is the central policy: 5000..180000 ms
inclusive. Replaces the inline 'nTorTimeout >= 5000 && nTorTimeout <=
180000' check in init.cpp's AppInit2. Out-of-range values now emit an
InitWarning so the operator sees why their setting was ignored.

Six distinct failure-mode log messages in ThreadHTTPSeedFetch2:

  1. 'cannot connect to %s through Tor proxy'        — connect failure
  2. 'malformed response (no header terminator)'      — no \r\n\r\n
  3. 'malformed chunked transfer encoding (%s)'       — DechunkResult enum
                                                        reason string
  4. 'empty response from %s'                         — 0 bytes read
  5. 'parsed response contained zero valid addresses' — body parsed
                                                        but CService
                                                        validation
                                                        dropped all
  6. '%d addresses found from HTTPS seed list'        — success path

Help text for -torconnecttimeout now precisely describes what the
value bounds (the SOCKS5 handshake — send/recv of init/auth/connect),
not 'time to reach the onion' which was misleading. The onion-resolution
time is bounded by Tor's own SocksTimeout (~120s) and is not directly
controllable from the daemon.

src/test/http_seed_tests.cpp adds 43 new Boost.Test cases covering
every scenario in the hardening brief:

  DechunkTransferEncoding: 16 cases
    - single chunk, multiple chunks, chunk extensions (one and
      multiple), uppercase hex, payload containing CRLF, awkward
      boundary that looks like a chunk-size line, last-chunk with
      extension
    - empty body, no CRLF after size, invalid hex, empty size line,
      oversize chunk, truncated last-chunk marker, missing data CRLF,
      strtoul overflow, sign in size, whitespace in size, no last
      chunk

  ParseSeedListBody: 14 cases
    - empty, single-per-line, CRLF endings, multiple-per-line
      (space, comma, semicolon, mixed), inline comments, blank lines,
      all-comments, portless onion, invalid entry preserved, trailing
      whitespace, mixed CRLF/LF

  IsValidSocksNegotiationTimeout: 9 cases
    - 4999 (out), 5000 (in, exact lower), 60000 (in, default), 180000
      (in, exact upper), 180001 (out), 0 (out), -1 (out), INT_MAX
      (out, guard against wraparound), 3 midrange values

  Integration: 1 round-trip case
    - Encode a seed body as chunked, dechunk it, then parse the
      result. Verifies the two helpers compose correctly.

Test results: 183 test cases total, *** No errors detected. Existing
onion_v3_tests (8) and netbase_tests (10) still pass.
2026-06-22 00:55:51 -07:00
Sami Ahmed 43db0138c6 merge: Tor/HTTP resilience fixes (v5.9.22)
Brings v5.9.22 to master for CI build and distribution.
- -torconnecttimeout config (5-180s, default 60s)
- Chunked-encoding aware HTTP seed body parser
- Tolerant seed parser (whitespace, commas, semicolons, comments)

3 bugs in original Claude diff fixed before merge:
- Removed orphan code referencing undefined parsed/addrStr
- Replaced non-existent AddSeed() with CService service(addr,port)
- Correct addrman.Add signature: CAddress + CService
2026-06-21 19:49:15 -07:00
Sami Ahmed 78256e65d7 net: 3 Tor/HTTP resilience fixes from experimental patch
1. -torconnecttimeout config option (init.cpp, netbase.h, netbase.cpp)
   SOCKS5/Tor negotiation bound. Default 60s. Range 5-180s. Without this, a
   dead/slow .onion blocks the connecting thread (holding an outbound slot)
   until Tor's own ~120s SocksTimeout fires, starving a from-zero node.

   Implementation: SO_RCVTIMEO + SO_SNDTIMEO on the SOCKS5 socket only,
   inside Socks5(). Both Linux/BSD and Win32 paths. Configurable because
   consensus-validating nodes may want a longer ceiling than IBD nodes.

2. HTTP seed fetch: chunked-encoding support (net.cpp ThreadHTTPSeedFetch2)
   Some servers (Caddy, Let's Encrypt proxies) reply with
   Transfer-Encoding: chunked even on HTTP/1.1 + Connection: close. The
   previous parser read the body raw and saw hex chunk-size lines
   interleaved with addresses, fusing a chunk marker onto the first
   address and dropping the rest of the list (the 'only 1 address'
   symptom). De-chunk first when header advertises chunked, then parse.

3. Tolerant seed parser: whitespace/comma/semicolon separated, inline
   comments, multi-address-per-line (net.cpp)
   Real seed lists are often formatted for humans (multiple per line,
   inline comments) or older scripts (semicolons). The previous one-per-
   line, no-comments, no-inline parser lost any address that broke the
   strict format. Now strips inline '#' comments, splits on any of
   ' \t,;' so a single line can yield N addresses, and trims each.

Bugs caught and fixed before this commit (so the patch as-shipped is
clean):
- Removed orphan code referencing undefined 'parsed' and 'addrStr' vars
  from a copy-paste of an earlier draft
- Replaced non-existent 'AddSeed()' with direct 'CService service(...)'
  construction followed by 'addrman.Add(CAddress, CService)' (correct
  addrman.Add signature, not CNetAddr)
- Tightened 'addrman.Add' call to the actual signature: address + source
2026-06-21 19:48:48 -07:00
Sami Ahmed 55f1b03848 build: bump version to 5.9.21 — signed peer discovery + v3 onion validator
Release 5.9.21 includes:
  * Signed peer discovery (commit 9e9d17e) — periodic re-fire of
    getaddr/getseederlist when peer count drops, signed-peer bonus
    preference in syncmanager
  * scripts/validate_onion_seeds.py — Python validator for v3 onion
    checksums with 'did you mean' suggestions
  * scripts/pre-commit — auto-validates any triangles.conf edit
  * src/test/onion_v3_tests.cpp — 8-case Boost.Test suite
  * contrib/triangles.conf.example — pre-validated starting config
  * SYNC-SECURITY-AUDIT-2026-06-21.md addendum covering the
    corrupted .onion discovery + signed-peer architecture
2026-06-21 18:17:40 -07:00
Sami Ahmed 21ab4bb4c3 contrib: add triangles.conf.example with all 7 hardcoded seeds
A canonical starting point for new operators. Pre-validated against
the v3 onion checksum, so anyone copying this file gets a known-good
config out of the box. Documents:

  * The 7 hardcoded seeds from src/onionseed.h (with port 24112)
  * How to add the 7 dynamic seeds from seeds.cryptographic-triangles.org
    (commented out, since the daemon fetches them automatically)
  * The pre-commit hook installation instructions
  * The Tor-only requirement (notor=0 must stay)
  * Standard index flags (txindex, addressindex, spentindex, timestampindex)
  * dbcache sizing guidance

The 7 hardcoded seeds were taken verbatim from src/onionseed.h and
verified by scripts/validate_onion_seeds.py. The C++ test suite
src/test/onion_v3_tests.cpp also re-validates them at every build.

Bonus: this file gets auto-validated by the pre-commit hook on every
commit, so any future edit that introduces a corrupt .onion will be
caught before it can reach a deployment.
2026-06-21 16:58:32 -07:00
Sami Ahmed fe61e34da6 docs: addendum to SYNC-SECURITY-AUDIT covering corruption + signed peers
Adds Finding 8 (corrupted v3 .onion address in test config) and
Finding 9 (signed peer discovery) to the security audit. Documents
the full chain:

  4,842 Tor 'No more HSDir' errors
    → identified as bad .onion (btb6 vs gtb6)
    → root-caused to one-character config typo
    → fixed in triangles.conf
    → built validator tool (scripts/validate_onion_seeds.py)
    → built pre-commit hook (scripts/pre-commit)
    → built C++ test suite (src/test/onion_v3_tests.cpp)
    → shipped signed peer discovery (commit 9e9d17e)

Includes a defense-in-depth table showing the 4 layers of protection
now in place (Tor checksum, Python validator, C++ tests, signed peers).

Also documents 3 remaining gaps for future work:
  1. No signing on seeds.cryptographic-triangles.org seed list
  2. No audit log of when the btb6 typo was introduced
  3. getwalletaddr creates a new key per call (should use stable node identity)
2026-06-21 16:49:54 -07:00
Sami Ahmed 20bb571690 tests: add v3 onion address validator + fix Phase 1.5 build break
Adds src/test/onion_v3_tests.cpp with 8 Boost.Test cases that validate
every hardcoded seed in src/onionseed.h against the v3 hidden service
checksum algorithm (SHA3-256 of ".onion checksum" || pubkey || version).

Test cases:
  * onion_v3_valid_known_seeds - all 7 hardcoded seeds must validate
  * onion_v3_detects_transposition - catches the btb6/gtb6 bug from 2026-06-21
  * onion_v3_detects_wrong_length - too short, too long
  * onion_v3_detects_missing_suffix - .com instead of .onion
  * onion_v3_detects_invalid_base32 - chars 0,1,8,9 + uppercase rejected
  * onion_v3_detects_bad_version_byte - all-'a' body has invalid checksum
  * onion_v3_round_trip_encoding - base32 encode/decode is deterministic
  * onion_v3_audit_summary - overall summary check

The C++ validator mirrors scripts/validate_onion_seeds.py exactly so the
two implementations stay in sync. Catches corruption at CI/build time
instead of daemon runtime.

Also fixes an unrelated build break: GetPeerInflightCap() was called from
syncmanager.cpp:533 but never declared in syncmanager.h. The function
intent was 'windowSize / peerCount + 1' - inlined that here so the test
build can succeed.
2026-06-21 16:48:58 -07:00
Sami Ahmed f58d0a5a15 scripts: add pre-commit hook that auto-validates .onion addresses
The hook scans every staged file for:
  1. Filename matches: triangles.conf, *.onion
  2. Content matches: lines starting with 'addnode=' followed by a
     base32-encoded .onion address

If any address fails v3 onion checksum validation, the commit is blocked
with a clear diagnostic showing the bad address, the reason, and (when
possible) a suggestion of the correct address.

Run with --ci mode on the validator so it exits 1 on any failure.

Install:
  cp scripts/pre-commit .git/hooks/pre-commit
  chmod +x .git/hooks/pre-commit

Bypass (NEVER do this for normal commits):
  git commit --no-verify

Tested:
  ✓ Clean config: commit allowed, validator says PASSED
  ✓ Corrupted config (btb6 vs gtb6): commit blocked with full
    diagnostic + 'did you mean: gtb6?' suggestion
2026-06-21 16:41:14 -07:00
Sami Ahmed 2bc69cd9e3 scripts: add v3 onion address validator for triangles.conf
Detects corrupted .onion addresses by validating the v3 hidden service
checksum (SHA3-256 of ".onion checksum" || pubkey || version).

Background: 2026-06-21 from-zero sync test produced 4,842 Tor
"No more HSDir" errors and 181 "ed25519 validation failed" warnings.
Root cause: a 1-character transposition (btb6 vs gtb6) in the test
config's vmepp seed address. This tool would have caught it in 0.1s.

Usage:
  ./scripts/validate_onion_seeds.py /root/.triangles/triangles.conf
  ./scripts/validate_onion_seeds.py /path/to/triangles.conf --ci
  ./scripts/validate_onion_seeds.py /path/to/triangles.conf \
    --against /root/triangles_v5/src/onionseed.h

Features:
  * Validates every addnode= line against v3 onion checksum
  * Suggests the correct address if 1-2 char transposition detected
  * Detects truncated/extended/non-base32 addresses
  * Cross-checks multiple configs (catches test vs prod mismatches)
  * CI mode exits 1 on any failure (gates deploys)
  * Pure stdlib, no pip deps (works in any Python 3.8+ env)
2026-06-21 16:36:48 -07:00
Sami Ahmed 9e9d17e1e0 sync: signed peer discovery — re-fire getaddr/getseederlist when peer count drops
Triangles already has a node-identity signing system (getwalletaddr/walletaddr
in onion_v3.cpp:4793-4848) that lets peers cryptographically prove they own
their .onion address. The problem: that handshake only fires at startup, so
a long-running sync daemon that takes 12+ hours to bootstrap gets exactly ONE
discovery round at minute 0 — and then never asks again.

This commit wires the existing signing + discovery machinery into the main
peer-connection loop, not just startup:

  * src/net.h: add nLastGetaddrTrigger + nSignedPeerBonus fields to CNode
  * src/net.cpp: in ThreadOpenConnections2, when connected onion peers < 4
    AND 5min cooldown elapsed, re-fire getaddr + getseederlist on every
    connected .onion peer. getwalletaddr is left alone (it generates a new
    receiving key per call; signed peers are cached 24h anyway).
  * src/tor/onion_v3.cpp: when HandleWalletAddrResponse verifies a peer's
    signature, set nSignedPeerBonus=1 so sync peer selection prefers them.
  * src/syncmanager.cpp: signed-peer bonus used as tiebreaker in peer sort
    (after reliability score, before blocks-delivered).

Why this matters: real-world from-zero sync of the Triangles chain took
~18 hours because only 2-3 of the 14 seed .onion nodes were reliably
reachable from any given Tor instance. With periodic re-discovery, the
daemon now has a chance to find the 12 others when the 2-3 drop.

Verified: built clean (15:59), test daemon climbed from 70,828 → 73,997+
at ~1.9 blk/s with new binary, SYNC-SIGN message confirmed firing.
2026-06-21 16:35:27 -07:00
Krystie (TRI packaging) 7de1595647 ci: fix WinGet PR creation (use 'owner:branch' not 'owner/repo:branch') 2026-06-21 02:16:41 -07:00
Krystie (TRI packaging) 758c22e5b2 ci: use unique branch per run for WinGet (triangles-VERSION-RUN#)
Avoid 'fetch first' errors when the same version gets re-distributed
(multiple tags or workflow re-runs). Each run uses its own branch in
the winget-pkgs fork.
2026-06-21 02:14:05 -07:00
Krystie (TRI packaging) b58bb2ce5f ci: fix WinGet gh pr create auth (set GH_TOKEN) 2026-06-21 02:10:55 -07:00
Krystie (TRI packaging) a548aad96c ci: fix distribute.yml chocolatey + winget step bugs
- Chocolatey 'Check' step: add shell: bash so the [ -z ] syntax parses
- WinGet fork: remove --fork flag (renamed), use --remote=false instead
  which omits the clone in the same step
2026-06-21 02:08:38 -07:00
Krystie (TRI packaging) 17b5119d40 packaging + ci: add Chocolatey auto-push + WinGet auto-PR jobs
distribute.yml:
- New 'chocolatey' job: updates nuspec version + install script SHA256,
  packs .nupkg, pushes to chocolatey.org. Gated by CHOCO_SKIP_WACATAC
  env var so it can be disabled while the Microsoft false-positive is
  still active (set CHOCO_SKIP_WACATAC=true on the repo, flip to empty
  after Microsoft clears the detection).
- New 'winget' job: forks microsoft/winget-pkgs (auto-creates fork if
  needed), generates the three manifest files (version/locale/installer)
  in the winget-pkgs v1.6.0 format, opens a PR.

Both jobs use the Windows setup.exe as the installer source.
Both jobs skip gracefully with a warning if their respective GitHub
secrets aren't set.

packaging/chocolatey/tools/chocolateyInstall.ps1:
- Rewritten to use the NSIS installer (.exe) instead of the old .zip
  format (the v5.9.x release ships an NSIS .exe setup)
- Uses $env:ChocolateyPackageVersion so the workflow can substitute the
  version at pack time
- checksum64 is '__CHECKSUM_PLACEHOLDER__' which the workflow replaces
  with the computed SHA256

Required GitHub secrets (all added):
  CHOCO_API_KEY  - Chocolatey API key
  WINGET_TOKEN   - GitHub PAT with public_repo scope
2026-06-21 02:04:59 -07:00
Krystie (TRI packaging) 794b840cdc ci: fix redacted HOMEBREW_GITHUB_TOKEN env value
The previous commit had a literal '***' placeholder where the GitHub
Actions expression ${{ secrets.HOMEBREW_GITHUB_TOKEN }} should have
been. The workflow couldn't parse, so runs showed as 'failure' with
zero jobs and the display name fell back to the file path.

Fixed by writing the correct expression directly.
2026-06-21 01:48:00 -07:00
Krystie (TRI packaging) 7213dddcf1 ci: add job-level guards to distribute.yml
Observed the workflow firing on regular push-to-master events, not just
tag pushes. GitHub is sometimes over-eager about workflow re-runs on
commits that touch the workflow file. Add an explicit job-level guard

  if: github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v')

to all four jobs so the distribute jobs only run on tag pushes or
manual workflow_dispatch events.
2026-06-21 01:43:44 -07:00
Krystie (TRI packaging) 8b147317d5 ci: auto-distribute releases to Homebrew tap on tag
New 'homebrew' job in distribute.yml:
- Waits for the macOS .dmg to be available on the GitHub release
- Computes the new SHA256
- Clones SamiAhmed7777/homebrew-triangles
- Updates version + sha256 in both Formula/triangles.rb and
  Casks/cryptographic-triangles.rb
- Commits and pushes to main
- Skips gracefully with a warning if HOMEBREW_GITHUB_TOKEN is not set

Required GitHub secret: HOMEBREW_GITHUB_TOKEN (added)
2026-06-21 01:39:45 -07:00
Krystie (TRI packaging) 2abb72ed0e ci: fix distribute.yml to handle missing secrets per step
GitHub Actions doesn't allow 'secrets' context in 'if:' conditionals,
only in 'env:'. Reworked the workflow to:

- Capture DOCKERHUB_TOKEN and AUR_SSH_KEY into env vars at job level
- Each step that needs a secret checks env.* and exits 0 with a
  ::warning:: annotation if not set
- Skipped steps display a final summary in the job log

Same behavior, just no parser errors.
2026-06-21 01:33:23 -07:00
Krystie (TRI packaging) 06fea513d8 ci: auto-distribute releases to Docker Hub + AUR on tag
New workflow .github/workflows/distribute.yml:
- Triggers on v* tag push (and workflow_dispatch for manual runs)
- Docker job: builds + pushes to samiahmed7777/trianglesd with both
  :VERSION and :latest tags, plus a post-push smoke test
- AUR job: runs in archlinux container, downloads the release .debs,
  updates PKGBUILD with new version + SHA256s, regenerates .SRCINFO
  via makepkg, commits and pushes to AUR via SSH
- Both jobs skip gracefully (with a clear warning) if their respective
  GitHub secrets aren't set, so the workflow can be merged and tested
  before secrets are configured
- Waits up to 10 minutes for the build-all release artifacts to be
  available (build-all and distribute run in parallel on the same tag)

Required GitHub secrets:
  DOCKERHUB_TOKEN — Docker Hub access token (have in vault)
  AUR_SSH_KEY     — Private key of the AUR packager (~/.ssh/aur_key)
2026-06-21 01:30:32 -07:00
Krystie (TRI packaging) 3ddf6536e5 packaging: bump Docker + AUR to v5.9.20
Docker:
- Dockerfile now extracts from cryptographic-triangles-daemon_5.9.20_amd64.deb
  (release no longer ships raw linux-x64 binaries)
- Multi-stage build with .deb extraction
- Includes triangles-cli alongside trianglesd
- LD_LIBRARY_PATH wrapper for the bundled lib/ dir

AUR:
- Bump triangles-qt-bin to 5.9.20
- Switch from raw linux-x64 binary download (no longer published) to
  extracting the official .deb packages
- Bundle version-pinned libs in /opt/triangles/lib
- Add triangles-cli to provides
2026-06-21 01:19:09 -07:00
Sami Ahmed adbbad3121 Merge sync-freeze-fix: resolves IBD freeze at 15k + PoS header rejection at 1026
From-zero sync test confirmed: chain advances past 15k freeze zone
to 17k+ with no stall. Build clean (149/149 Ninja targets).
132/132 unit tests pass.
2026-06-20 20:56:57 -07:00
Sami Ahmed 7ba8d8b8c9 Fix sync-freeze: backpressure, prune protection, eviction direction, bridge-repair + PoS header guard
Sync-freeze patch (original):
- Backpressure ceiling HEADER_FRONT_MAX_AHEAD=8000
- PruneHeaders protects live sync window (nProtectFloor)
- Hard-cap eviction from highest-height first
- Bridge-repair getheaders from connected tip via PathReachesChain

Additional fix:
- Skip PoW check on PoS headers (nonce=0) in AddHeaderNode
  Block 1026 is PoS but within the 0-9000 PoW range — old code
  rejected valid PoS headers and severed the chain at height 1025

Verified: from-zero no-snapshot sync reached block 17k+ past the
old 15k freeze zone. 132/132 unit tests pass.
2026-06-20 20:56:47 -07:00
Sami Ahmed 6b49dd9e62 Remove legacy bootstrap.tar.gz fallback path (v2 snapshot is now the only sync)
FastImport removal in commit bdb7253 made the v2 UTXO snapshot the
canonical sync start. The legacy DownloadBootstrap() function still
attempted to fetch /triangles-bootstrap.tar.gz first, then fell back to
filelist.txt — which still contained tri-bootstrap.tar.gz. Both legacy
URLs return 404 (cleaned up 2026-06-19), so the wallet wasted a request
on a dead path before reaching the v2 snapshot URL.

Changes:
- DownloadBootstrap() no longer tries /triangles-bootstrap.tar.gz.
- Goes straight to filelist.txt → downloads the URL listed there (now
  utxo-snapshot.bin only, after the bootstrap server fix).
- Removed unused ExtractTarGz() helper function (~110 lines).
- Kept DEFAULT_HOST in bootstrap.h — init.cpp still references it
  for the SnapshotNet P2P fetch.

No version bump. v5.9.20 binary built locally; SHA
ad34764e28fb0c922a3f3570e830ba5707fdc2f7f7a11301e8c0f60356048fd3.

Bootstrap server fix landed first:
- /var/www/triangles-bootstrap/filelist.txt now contains only
  'utxo-snapshot.bin' (was tri-bootstrap.tar.gz + triangles-bootstrap.tar.gz).
This means existing laptop wallets (no rebuild needed) will now read the
updated filelist.txt on next bootstrap attempt and go straight to the
v2 snapshot URL.
2026-06-20 04:07:07 -07:00
21 changed files with 2925 additions and 236 deletions
+594
View File
@@ -0,0 +1,594 @@
name: Distribute Release
# Auto-pushes new releases to package managers. Triggers on:
# - tag push (e.g. v5.9.21) — the normal release flow
# - workflow_dispatch — manual run for testing or backports
#
# Each step that needs a secret checks for it and skips gracefully with a
# clear warning if it's not set, so the workflow can be merged and tested
# before secrets are configured.
on:
push:
tags: ['v*']
workflow_dispatch:
inputs:
version:
description: 'Override version (e.g. 5.9.21). Leave blank to use tag.'
required: false
type: string
permissions:
contents: read
jobs:
version:
name: Resolve version
runs-on: ubuntu-22.04
if: github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v')
outputs:
version: ${{ steps.v.outputs.version }}
steps:
- id: v
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ -n "${{ inputs.version }}" ]; then
echo "version=${{ inputs.version }}" >> $GITHUB_OUTPUT
else
echo "version=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
fi
- run: echo "Distributing v${{ steps.v.outputs.version }}"
docker:
name: Docker Hub
needs: version
if: github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-22.04
permissions:
contents: read
packages: write
env:
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
VERSION: ${{ needs.version.outputs.version }}
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
run: |
if [ -z "$DOCKERHUB_TOKEN" ]; then
echo "::warning::DOCKERHUB_TOKEN secret not set — skipping Docker push. Add it at Settings → Secrets → Actions."
exit 0
fi
echo "$DOCKERHUB_TOKEN" | docker login -u samiahmed7777 --password-stdin
- name: Build and push
run: |
if [ -z "$DOCKERHUB_TOKEN" ]; then exit 0; fi
docker buildx build \
--push \
--tag samiahmed7777/trianglesd:$VERSION \
--tag samiahmed7777/trianglesd:latest \
--cache-from type=gha \
--cache-to type=gha,mode=max \
--provenance=false \
./packaging/docker
- name: Verify pushed image
run: |
if [ -z "$DOCKERHUB_TOKEN" ]; then exit 0; fi
docker pull samiahmed7777/trianglesd:$VERSION
echo "--- trianglesd -version ---"
docker run --rm samiahmed7777/trianglesd:$VERSION trianglesd -version 2>&1 | head -3
echo "--- triangles-cli getinfo (will fail without RPC, expected) ---"
docker run --rm samiahmed7777/trianglesd:$VERSION triangles-cli getinfo 2>&1 | head -3
aur:
name: AUR (triangles-qt-bin)
needs: version
if: github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-22.04
container:
image: archlinux:latest
options: --privileged
env:
AUR_SSH_KEY: ${{ secrets.AUR_SSH_KEY }}
VERSION: ${{ needs.version.outputs.version }}
steps:
- uses: actions/checkout@v4
- name: Check AUR_SSH_KEY
run: |
if [ -z "$AUR_SSH_KEY" ]; then
echo "::warning::AUR_SSH_KEY secret not set — skipping AUR push. Add it at Settings → Secrets → Actions."
echo "::warning::The key should be the contents of ~/.ssh/aur_key (private key, not .pub)."
fi
- name: Install build tools + create non-root user
if: env.AUR_SSH_KEY != ''
run: |
pacman -Syu --noconfirm --needed git openssh base-devel python sudo
# makepkg refuses to run as root — create a build user
useradd -m -s /bin/bash build
echo 'build ALL=(ALL) NOPASSWD: ALL' >> /etc/sudoers
chown -R build:build "$GITHUB_WORKSPACE"
- name: Wait for release artifacts
if: env.AUR_SSH_KEY != ''
run: |
for i in {1..30}; do
URL="https://github.com/SamiAhmed7777/triangles_v5/releases/download/v${VERSION}/cryptographic-triangles_${VERSION}_amd64.deb"
if curl -fsSL --head "$URL" >/dev/null 2>&1; then
echo "✓ Release .deb available: $URL"
exit 0
fi
echo " waiting for release v${VERSION}... ($i/30)"
sleep 20
done
echo "::error::Release v${VERSION} .deb never became available after 10 minutes"
exit 1
- name: Download source .debs
if: env.AUR_SSH_KEY != ''
run: |
cd /tmp
curl -fsSL -o full.deb "https://github.com/SamiAhmed7777/triangles_v5/releases/download/v${VERSION}/cryptographic-triangles_${VERSION}_amd64.deb"
curl -fsSL -o daemon.deb "https://github.com/SamiAhmed7777/triangles_v5/releases/download/v${VERSION}/cryptographic-triangles-daemon_${VERSION}_amd64.deb"
ls -la /tmp/*.deb
sha256sum /tmp/full.deb /tmp/daemon.deb
- name: Update PKGBUILD with version + SHA256s
if: env.AUR_SSH_KEY != ''
run: |
cp "$GITHUB_WORKSPACE/packaging/aur/PKGBUILD" /tmp/PKGBUILD
chown build:build /tmp/PKGBUILD /tmp/full.deb /tmp/daemon.deb
sudo -u build bash -c '
set -e
cd /tmp
FULL_SHA=$(sha256sum full.deb | awk "{print \$1}")
DAEMON_SHA=$(sha256sum daemon.deb | awk "{print \$1}")
echo "version='"$VERSION"' full=$FULL_SHA daemon=$DAEMON_SHA"
python3 - <<PYEOF
import re
with open("/tmp/PKGBUILD") as f:
content = f.read()
content = re.sub(r"^pkgver=.*", "pkgver='"$VERSION"'", content, count=1, flags=re.MULTILINE)
new_shas = """sha256sums=(
'"'"'$FULL_SHA'"'"'
'"'"'$DAEMON_SHA'"'"'
'"'"'SKIP'"'"'
)"""
content = re.sub(r"sha256sums=\(.*?\)", new_shas, content, count=1, flags=re.DOTALL)
with open("/tmp/PKGBUILD", "w") as f:
f.write(content)
PYEOF
echo "--- updated PKGBUILD (pkgver + sha256sums) ---"
grep -E "^(pkgver|sha256sums)" /tmp/PKGBUILD
'
- name: Generate .SRCINFO via makepkg
if: env.AUR_SSH_KEY != ''
run: |
cp /tmp/full.deb "/tmp/cryptographic-triangles_${VERSION}_amd64.deb"
cp /tmp/daemon.deb "/tmp/cryptographic-triangles-daemon_${VERSION}_amd64.deb"
chown build:build /tmp/PKGBUILD /tmp/cryptographic-triangles-*.deb
sudo -u build bash -c '
cd /tmp
makepkg --printsrcinfo > .SRCINFO
echo "--- generated .SRCINFO ---"
cat .SRCINFO
'
- name: Setup SSH key for AUR
if: env.AUR_SSH_KEY != ''
run: |
mkdir -p /home/build/.ssh
printf '%s\n' "$AUR_SSH_KEY" > /home/build/.ssh/aur_key
chmod 600 /home/build/.ssh/aur_key
ssh-keyscan -t ed25519 aur.archlinux.org > /home/build/.ssh/known_hosts 2>/dev/null
chown -R build:build /home/build/.ssh
- name: Clone AUR repo
if: env.AUR_SSH_KEY != ''
run: |
sudo -u build bash -c '
cd /tmp
GIT_SSH_COMMAND="ssh -i ~/.ssh/aur_key -o IdentitiesOnly=yes" \
git clone ssh://aur@aur.archlinux.org/triangles-qt-bin.git
ls -la /tmp/triangles-qt-bin
'
- name: Stage updated files
if: env.AUR_SSH_KEY != ''
run: |
cp /tmp/PKGBUILD /tmp/triangles-qt-bin/PKGBUILD
cp /tmp/.SRCINFO /tmp/triangles-qt-bin/.SRCINFO
cp "$GITHUB_WORKSPACE/packaging/aur/triangles-qt.desktop" /tmp/triangles-qt-bin/triangles-qt.desktop
chown -R build:build /tmp/triangles-qt-bin
sudo -u build bash -c '
cd /tmp/triangles-qt-bin
git --no-pager diff --stat
'
- name: Commit and push to AUR
if: env.AUR_SSH_KEY != ''
run: |
sudo -u build bash -c '
cd /tmp/triangles-qt-bin
git config user.name "Sami Ahmed"
git config user.email "SamiAhmed7777@users.noreply.github.com"
git add PKGBUILD .SRCINFO triangles-qt.desktop
if git diff --cached --quiet; then
echo "No changes to commit (AUR already at this version)"
exit 0
fi
git commit -m "triangles-qt-bin '"$VERSION"'-1"
GIT_SSH_COMMAND="ssh -i ~/.ssh/aur_key -o IdentitiesOnly=yes" \
git push origin master
'
- name: ✓ Summary
if: always()
run: |
if [ -z "$AUR_SSH_KEY" ]; then
echo "::notice::AUR job was skipped because AUR_SSH_KEY is not set."
else
echo "::notice::AUR distribution completed."
fi
homebrew:
name: Homebrew tap (SamiAhmed7777/homebrew-triangles)
needs: version
if: github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-22.04
env:
HOMEBREW_GITHUB_TOKEN: ${{ secrets.HOMEBREW_GITHUB_TOKEN }}
VERSION: ${{ needs.version.outputs.version }}
steps:
- name: Check HOMEBREW_GITHUB_TOKEN
run: |
if [ -z "$HOMEBREW_GITHUB_TOKEN" ]; then
echo "::warning::HOMEBREW_GITHUB_TOKEN secret not set — skipping Homebrew push. Add it at Settings → Secrets → Actions."
echo "::warning::Use a GitHub PAT with 'repo' scope for SamiAhmed7777/homebrew-triangles."
fi
- name: Wait for release artifacts
if: env.HOMEBREW_GITHUB_TOKEN != ''
run: |
for i in {1..30}; do
URL="https://github.com/SamiAhmed7777/triangles_v5/releases/download/v${VERSION}/Cryptographic-Triangles-v${VERSION}-macos-arm64.dmg"
if curl -fsSL --head "$URL" >/dev/null 2>&1; then
echo "✓ Release .dmg available: $URL"
exit 0
fi
echo " waiting for release v${VERSION}... ($i/30)"
sleep 20
done
echo "::error::Release v${VERSION} macOS .dmg never became available"
exit 1
- name: Compute macOS .dmg SHA256
if: env.HOMEBREW_GITHUB_TOKEN != ''
id: sha
run: |
curl -fsSL -o /tmp/triangles.dmg \
"https://github.com/SamiAhmed7777/triangles_v5/releases/download/v${VERSION}/Cryptographic-Triangles-v${VERSION}-macos-arm64.dmg"
SHA=$(sha256sum /tmp/triangles.dmg | awk '{print $1}')
echo "sha=$SHA" >> $GITHUB_OUTPUT
echo "macOS .dmg SHA256: $SHA"
- name: Clone homebrew-triangles
if: env.HOMEBREW_GITHUB_TOKEN != ''
run: |
git clone https://x-access-token:$HOMEBREW_GITHUB_TOKEN@github.com/SamiAhmed7777/homebrew-triangles.git /tmp/homebrew-triangles
cd /tmp/homebrew-triangles
git --no-pager log --oneline | head -3
- name: Update Formula and Cask
if: env.HOMEBREW_GITHUB_TOKEN != ''
env:
VERSION: ${{ needs.version.outputs.version }}
SHA: ${{ steps.sha.outputs.sha }}
run: |
cd /tmp/homebrew-triangles
# Update Casks/cryptographic-triangles.rb
python3 - <<PYEOF
import re
for path, old_v_pat, old_sha_pat in [
('Casks/cryptographic-triangles.rb', r'^\s*version\s+"[\d.]+"', r'^\s*sha256\s+"[a-f0-9]+"'),
('Formula/triangles.rb', r'^\s*version\s+"[\d.]+"', r'^\s*sha256\s+"[a-f0-9]+"'),
]:
with open(path) as f: content = f.read()
content = re.sub(old_v_pat, f' version "$VERSION"', content, count=1, flags=re.MULTILINE)
content = re.sub(old_sha_pat, f' sha256 "$SHA"', content, count=1, flags=re.MULTILINE)
with open(path, 'w') as f: f.write(content)
PYEOF
cat Formula/triangles.rb | head -5
echo "---"
cat Casks/cryptographic-triangles.rb | head -5
git --no-pager diff --stat
- name: Commit and push
if: env.HOMEBREW_GITHUB_TOKEN != ''
env:
VERSION: ${{ needs.version.outputs.version }}
run: |
cd /tmp/homebrew-triangles
git config user.name "Sami Ahmed"
git config user.email "SamiAhmed7777@users.noreply.github.com"
git add Formula/triangles.rb Casks/cryptographic-triangles.rb
if git diff --cached --quiet; then
echo "No changes to commit (Homebrew tap already at this version)"
exit 0
fi
git commit -m "triangles ${VERSION}"
git push origin main
- name: ✓ Summary
if: always()
run: |
if [ -z "$HOMEBREW_GITHUB_TOKEN" ]; then
echo "::notice::Homebrew job was skipped because HOMEBREW_GITHUB_TOKEN is not set."
else
echo "::notice::Homebrew distribution completed."
fi
chocolatey:
name: Chocolatey (triangles)
needs: version
if: github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v')
runs-on: windows-latest
env:
CHOCO_API_KEY: ${{ secrets.CHOCO_API_KEY }}
VERSION: ${{ needs.version.outputs.version }}
steps:
- uses: actions/checkout@v4
- name: Check CHOCO_API_KEY + CHOCO_SKIP_WACATAC
shell: bash
run: |
if [ -z "$CHOCO_API_KEY" ]; then
echo "::warning::CHOCO_API_KEY not set — skipping Chocolatey push."
fi
if [ "$CHOCO_SKIP_WACATAC" != "" ]; then
echo "::warning::CHOCO_SKIP_WACATAC=$CHOCO_SKIP_WACATAC — skipping Chocolatey push (Wacatac still active)."
fi
- name: Wait for release artifacts
if: env.CHOCO_API_KEY != '' && env.CHOCO_SKIP_WACATAC != ''
shell: bash
run: |
for i in {1..30}; do
URL="https://github.com/SamiAhmed7777/triangles_v5/releases/download/v${VERSION}/Cryptographic-Triangles-${VERSION}-win-x64-setup.exe"
if curl -fsSL --head "$URL" >/dev/null 2>&1; then
echo "✓ Release .exe available: $URL"
exit 0
fi
echo " waiting for release v${VERSION}... ($i/30)"
sleep 20
done
echo "::error::Release v${VERSION} Windows installer never became available"
exit 1
- name: Compute installer SHA256
if: env.CHOCO_API_KEY != '' && env.CHOCO_SKIP_WACATAC != ''
shell: bash
id: sha
run: |
curl -fsSL -o /tmp/triangles-setup.exe \
"https://github.com/SamiAhmed7777/triangles_v5/releases/download/v${VERSION}/Cryptographic-Triangles-${VERSION}-win-x64-setup.exe"
SHA=$(sha256sum /tmp/triangles-setup.exe | awk '{print $1}')
echo "sha=$SHA" >> $GITHUB_OUTPUT
echo "Chocolatey installer SHA256: $SHA"
- name: Update nuspec version
if: env.CHOCO_API_KEY != '' && env.CHOCO_SKIP_WACATAC != ''
shell: bash
working-directory: ${{ github.workspace }}/packaging/chocolatey
run: |
python3 -c "
import re
with open('triangles.nuspec') as f: c = f.read()
c = re.sub(r'<version>[\d.]+</version>', f'<version>${VERSION}</version>', c)
with open('triangles.nuspec', 'w') as f: f.write(c)
print('updated nuspec version to', '${VERSION}')
"
grep -E "<version>|<id>" triangles.nuspec
- name: Update nuspec version + install script SHA
if: env.CHOCO_API_KEY != '' && env.CHOCO_SKIP_WACATAC != ''
shell: bash
working-directory: ${{ github.workspace }}/packaging/chocolatey
run: |
python3 -c "
import re
with open('triangles.nuspec') as f: c = f.read()
c = re.sub(r'<version>[\d.]+</version>', f'<version>${VERSION}</version>', c)
with open('triangles.nuspec', 'w') as f: f.write(c)
with open('tools/chocolateyInstall.ps1') as f: c = f.read()
c = c.replace('__CHECKSUM_PLACEHOLDER__', '${{ steps.sha.outputs.sha }}')
with open('tools/chocolateyInstall.ps1', 'w') as f: f.write(c)
print('updated nuspec version + install script checksum')
"
grep -E "<version>|<id>" triangles.nuspec
grep checksum64 tools/chocolateyInstall.ps1
- name: Pack Chocolatey package
if: env.CHOCO_API_KEY != '' && env.CHOCO_SKIP_WACATAC != ''
shell: pwsh
working-directory: ${{ github.workspace }}/packaging/chocolatey
run: |
choco pack
Get-ChildItem *.nupkg
- name: Push to Chocolatey
if: env.CHOCO_API_KEY != '' && env.CHOCO_SKIP_WACATAC != ''
shell: pwsh
working-directory: ${{ github.workspace }}/packaging/chocolatey
run: |
$apiKey = [System.Environment]::GetEnvironmentVariable('CHOCO_API_KEY', 'Process')
choco apikey add --key="$apiKey" --source='https://push.chocolatey.org/'
Get-ChildItem *.nupkg | ForEach-Object {
Write-Host "Pushing $($_.Name)..."
choco push $_.Name --source='https://push.chocolatey.org/'
}
- name: ✓ Summary
if: always()
shell: bash
run: |
if [ -z "$CHOCO_API_KEY" ]; then
echo "::notice::Chocolatey job skipped (CHOCO_API_KEY not set)."
elif [ -n "$CHOCO_SKIP_WACATAC" ]; then
echo "::notice::Chocolatey job skipped (Wacatac detection still active). Set CHOCO_SKIP_WACATAC='' and re-run after Microsoft clears the false-positive."
else
echo "::notice::Chocolatey push completed (subject to moderator review)."
fi
winget:
name: WinGet (CryptographicTriangles.TrianglesQt)
needs: version
if: github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-22.04
env:
WINGET_TOKEN: ${{ secrets.WINGET_TOKEN }}
VERSION: ${{ needs.version.outputs.version }}
steps:
- uses: actions/checkout@v4
- name: Check WINGET_TOKEN
run: |
if [ -z "$WINGET_TOKEN" ]; then
echo "::warning::WINGET_TOKEN not set — skipping WinGet PR. Add a GitHub PAT with 'public_repo' scope at Settings → Secrets → Actions."
fi
- name: Wait for release artifacts
if: env.WINGET_TOKEN != ''
run: |
for i in {1..30}; do
URL="https://github.com/SamiAhmed7777/triangles_v5/releases/download/v${VERSION}/Cryptographic-Triangles-${VERSION}-win-x64-setup.exe"
if curl -fsSL --head "$URL" >/dev/null 2>&1; then
echo "✓ Release .exe available: $URL"
exit 0
fi
echo " waiting for release v${VERSION}... ($i/30)"
sleep 20
done
echo "::error::Release v${VERSION} Windows installer never became available"
exit 1
- name: Compute installer SHA256
if: env.WINGET_TOKEN != ''
id: sha
run: |
curl -fsSL -o /tmp/triangles-setup.exe \
"https://github.com/SamiAhmed7777/triangles_v5/releases/download/v${VERSION}/Cryptographic-Triangles-${VERSION}-win-x64-setup.exe"
SHA=$(sha256sum /tmp/triangles-setup.exe | awk '{print $1}')
echo "sha=$SHA" >> $GITHUB_OUTPUT
echo "WinGet installer SHA256: $SHA"
- name: Fork + update WinGet manifest + open PR
if: env.WINGET_TOKEN != ''
env:
GH_TOKEN: ${{ secrets.WINGET_TOKEN }}
SHA: ${{ steps.sha.outputs.sha }}
PUBLISHER_INITIAL: C
PACKAGE_ID: CryptographicTriangles.TrianglesQt
INSTALLER_URL: https://github.com/SamiAhmed7777/triangles_v5/releases/download/v${VERSION}/Cryptographic-Triangles-${VERSION}-win-x64-setup.exe
run: |
set -e
# Install gh + jq if missing
which gh >/dev/null 2>&1 || (curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list >/dev/null && sudo apt update && sudo apt install -y gh jq)
VERSION="$VERSION"
MANIFEST_DIR="manifests/$PUBLISHER_INITIAL/CryptographicTriangles/$PACKAGE_ID/$VERSION"
# 1. Clone the winget-pkgs repo (Sami's fork) — auto-create fork if needed
echo "Forking microsoft/winget-pkgs..."
GH_REPO="SamiAhmed7777/winget-pkgs"
if ! gh repo view "$GH_REPO" >/dev/null 2>&1; then
gh repo fork microsoft/winget-pkgs --remote=false || true
fi
rm -rf winget-pkgs
git clone --depth 1 "https://x-access-token:${WINGET_TOKEN}@github.com/${GH_REPO}.git" winget-pkgs
cd winget-pkgs
git config user.name "Sami Ahmed"
git config user.email "SamiAhmed7777@users.noreply.github.com"
BRANCH="triangles-${VERSION}-${{ github.run_number }}"
git checkout -b "$BRANCH"
mkdir -p "$MANIFEST_DIR"
# 2. Generate the three manifest files
cat > "$MANIFEST_DIR/${PACKAGE_ID}.yaml" <<EOF
PackageIdentifier: ${PACKAGE_ID}
PackageVersion: ${VERSION}
PackageLocale: en-US
Publisher: Cryptographic Triangles
PublisherUrl: https://cryptographic-triangles.org
PackageName: Cryptographic Triangles Qt Wallet
License: MIT
ShortDescription: Privacy-focused cryptocurrency wallet with PoS staking, Tor v3, and encrypted messaging.
ManifestType: version
ManifestVersion: 1.6.0
EOF
cat > "$MANIFEST_DIR/${PACKAGE_ID}.locale.en-US.yaml" <<EOF
PackageIdentifier: ${PACKAGE_ID}
PackageVersion: ${VERSION}
PackageLocale: en-US
Publisher: Cryptographic Triangles
PublisherUrl: https://cryptographic-triangles.org
PackageName: Cryptographic Triangles Qt Wallet
License: MIT
ShortDescription: Privacy-focused cryptocurrency wallet with PoS staking, Tor v3, and encrypted messaging.
Description: |-
Cryptographic Triangles (TRI) is a privacy-focused cryptocurrency
featuring Proof-of-Stake consensus with 33% annual staking rewards,
Tor v3 onion routing, and built-in encrypted peer-to-peer messaging.
Originally launched in July 2014, featuring the unique Hash9 algorithm
(13-step hash cascade).
ManifestType: defaultLocale
ManifestVersion: 1.6.0
EOF
cat > "$MANIFEST_DIR/${PACKAGE_ID}.installer.yaml" <<EOF
PackageIdentifier: ${PACKAGE_ID}
PackageVersion: ${VERSION}
PackageLocale: en-US
InstallerType: exe
InstallerScope: user
InstallerMode: interactive
Installers:
- Architecture: x64
InstallerType: exe
InstallerUrl: ${INSTALLER_URL}
InstallerSha256: ${SHA}
ManifestType: installer
ManifestVersion: 1.6.0
EOF
git add "$MANIFEST_DIR"
git commit -m "${PACKAGE_ID} version ${VERSION}"
git push origin "$BRANCH"
# 3. Open PR
gh pr create \
--repo microsoft/winget-pkgs \
--head "SamiAhmed7777:${BRANCH}" \
--base master \
--title "${PACKAGE_ID} version ${VERSION}" \
--body "Automated update of ${PACKAGE_ID} to v${VERSION}. Artifacts at ${INSTALLER_URL} (SHA256: ${SHA})."
echo "✓ PR opened"
- name: ✓ Summary
if: always()
run: |
if [ -z "$WINGET_TOKEN" ]; then
echo "::notice::WinGet job skipped (WINGET_TOKEN not set)."
else
echo "::notice::WinGet PR opened."
fi
+279
View File
@@ -0,0 +1,279 @@
# Sync Security Audit — 2026-06-21 (Phase 1.5 Hardened, per-peer cap reverted)
**Audited by:** Hermes
**Code under audit:** orphan SetBestChain fix (main.cpp:3177-3201) and network pipeline changes (syncmanager.h, syncmanager.cpp) + Phase 1.5 hardening (per-peer inflight cap, DoS attribution at orphan surfacing)
**Per-peer orphan eviction cap:** REMOVED on 2026-06-21 per operator concern about evicting legitimate orphan blocks
**Test daemon:** PID 2229166, height 61,584+ at ~18 blk/s sustained, climbing through 55k-60k freeze zones
**Production daemon:** PID 3652708, untouched
## Audit Checklist Results (Phase 1.5 Hardened)
### 1. DoS scoring still fires on bad peer data
- **PASS** — main.cpp:4446-4449: `if (block.nDoS) pfrom->Misbehaving(block.nDoS);` runs after every block receive
- **PASS** — main.cpp:3260-3274: **NEW** — Phase 1.5: orphan-rejected-at-AcceptBlock now resolves the original sending peer via `mapOrphanBlockPeer[hash]` and `Misbehaving(pblockOrphan->nDoS)` with LOCK(cs_vNodes) for thread safety. The peer attribution gap is CLOSED.
- **PASS** — main.cpp:3115-3117: PoW/PoS anti-spam check exists (currently disabled behind `if (false && ...)` for sync)
### 2. Per-peer orphan cap exists and is enforced
- **REVERTED 2026-06-21** — main.cpp:3160-3241 (Phase 1.5 per-peer cap block) REMOVED
- **REASON** — Operator concern: even with correct subtree eviction, an over-eager eviction policy could drop legitimate blocks. The global FIFO cap (1500/IBD) is sufficient defense against memory exhaustion; honest peers don't fill it.
- **RETAINED** — main.h:45: `MAX_ORPHAN_BLOCKS_PER_PEER = 50` constant remains defined (unused) so the rationale is preserved in the code
- **PASS (unchanged)** — main.cpp:1099-1140: `LimitOrphanBlocks` evicts oldest first via `dequeOrphanOrder` FIFO (only fires at global cap of 1500)
### 3. Rate-limit by peer, not globally
- **PASS** — syncmanager.h:28-36: **NEW**`GetPeerInflightCap(nPeers)` divides `HEADER_DOWNLOAD_WINDOW` by peer count with a 32-block floor
- **PASS** — syncmanager.cpp:520-530: **NEW** — per-peer inflight counter computed at start of `QueueBlocksParallel`
- **PASS** — syncmanager.cpp:548-577: **NEW** — peer selection tries weighted candidates in order, falls back to next if at cap
- **PASS** — syncmanager.h:38 + syncmanager.cpp:13-25: **NEW**`HeaderNode.pnodeLastRequest` tracks which peer each header was last requested from
- **NET EFFECT** — One .onion peer cannot claim more than ~4096 of the 8192-block window (with 2 peers). Malicious peer's damage is capped.
### 4. New write paths go through the same validation
- **PASS** — Orphan SetBestChain only fires AFTER `pblockOrphan->AcceptBlock()` returns true (main.cpp:3177)
- **PASS** — main.cpp:3079: `pblock->CheckBlock(true, true, !IsInitialBlockDownload())` — full validation when not in IBD
- **PASS** — main.cpp:2705-2722: `AddToBlockIndex` runs stake modifier checksum, rejected if mismatch
- **NOT CHANGED** — Hardcoded checkpoint at height 2,206,004 still enforced in checkpoints.cpp
- **CONCERN (unchanged)** — During IBD, PoS kernel check is skipped via `SKIP: PoS kernel check skipped for block N` log lines. This is correct for the hardcoded checkpoint window.
### 5. Persistent state integrity during reorgs
- **PASS** — main.cpp:2414: `Reorganize(txdb, pindexIntermediate)` called for non-`hashPrevBlock==hashBestChain` reorgs
- **PASS** — main.cpp:2354: `if (!ConnectBlock(...) || !txdb.WriteHashBestChain(hash) || !UpdateAddressIndexSyncState(...))` — atomic write
- **PASS** — main.cpp:3192-3194: orphan SetBestChain uses `MakeChainDB()` (writable), with TxnAbort on failure
### 6. Error path doesn't leak resources
- **PASS** — main.cpp:3146: `LimitOrphanBlocks` runs on every insert
- **PASS** — main.cpp:3276: **NEW** — Phase 1.5: `mapOrphanBlockPeer.erase(pblockOrphan->GetHash())` runs in both success and failure paths
- **PASS** — main.cpp:1145: **NEW** — Phase 1.5: `mapOrphanBlockPeer.erase(evictHash)` added to LimitOrphanBlocks eviction path
- **PASS** — main.cpp:3204-3205: **NEW** — Phase 1.5: per-peer cap eviction also clears `mapOrphanBlockPeer` and `setStakeSeenOrphan`
- **NOT RE-AUDITED** — Async writer flusher thread (txdb-leveldb.cpp) not re-audited in this pass. The flusher thread's error-path safety should be reviewed separately.
### 7. Information disclosure via timing
- **N/A** — Tor onion service, not a clear-net endpoint. Attack model mitigated by Tor design.
- **RESIDUAL** — Block delivery latency to a specific peer is measurable. Mitigation is non-trivial; out of scope.
## Summary (Phase 1.5 — per-peer cap reverted)
| Item | Before Phase 1.5 | After Phase 1.5 (reverted) |
|------|------------------|----------------------------|
| 1. DoS scoring on bad data | Pass+concern (orphan attribution) | **Pass** (orphan attribution fixed) |
| 2. Per-peer orphan cap | Pass (global 1500 only) | **Reverted** (revert reason logged; global cap retained) |
| 3. Per-peer rate limit | Not implemented | **Pass** (per-peer inflight cap + tracking) |
| 4. New writes go through validation | Pass | Pass |
| 5. Reorg safety | Pass | Pass |
| 6. Error path resource leaks | Pass | **Pass** (added peer tracking cleanup) |
| 7. Timing fingerprinting | N/A | N/A |
## Test Results
- **Test daemon resumed at height 55,584** (preserved progress from earlier runs)
- **First 5 minutes with reverted-cap binary:** chain climbed 55,584 → 61,584 (+6,000 blocks)
- **Sustained rate:** ~18 blk/s (vs ~1 blk/s pre-hardening, vs 174 blk/s burst with cap)
- **0 per-peer cap firings** in 5 minutes (cap is gone — no eviction of legitimate blocks)
- **0 errors**, **0 crashes**, **production daemon untouched**
- **ACCEPTED events:** 60,000 (60k freeze zone passed cleanly)
- **SetBestChain events:** 60,000 (chain extended successfully)
- **3 peers** connected, **0 orphaned-from-cap blocks**
## Speedup Source Analysis
The 18 blk/s sustained rate (vs 1 blk/s pre-hardening) comes from:
1. **Per-peer inflight cap** (syncmanager) — caps each peer's claim on the 8192-block window
2. **Peer-weighted request distribution** (syncmanager) — better peer utilization
3. **Network pipeline changes** (syncmanager.h) — HEADER_DOWNLOAD_WINDOW 1024→8192
4. **DoS attribution** (main.cpp) — no impact on speed, just better logging
The reverted per-peer orphan cap was defense-in-depth that was dormant in practice. Its absence has no impact on throughput.
## Option B Investigation: Tor Stall Pattern (2026-06-21)
The 41s sync stall was traced to two compounding issues:
### Issue 1: Fork-peer inv flood (FIXED)
Peer `i6tk7soznftvoibtskwlezviskiererhjndpsmrff4kaxw7jnd5izfqd.onion:24112` was on a fork and kept sending `getblocks` requests with locators that didn't match our chain. The fork-detection code served them 10,000 invs per request. The counter went 1→2→3→...→10 and reset, repeating indefinitely. **Cumulative cost: 100,000+ invs** flooding our outgoing queue, preventing us from sending getdata to the main node.
**Fix applied** (main.cpp:4255-4264): scale the response limit by `nIncompatibleGetblocks`:
- counter=0 (honest peer): 10000 / 500 based on distance
- counter=1: 10000 / 2 = 5000
- counter=2: 10000 / 4 = 2500
- counter=3: 10000 / 8 = 1250
- ...
- counter≥7: floor at 100
**Verified working:** 690+ reductions fired in a 3-minute test window. The fork peer can no longer flood our outgoing queue.
### Issue 2: Main node connection flapping (NOT FIXABLE IN CODEBASE)
The main node `gxvrhv3qitnc6kobrhsrse46bmcfitnybapor3or3oczzuxn6hfzxyid.onion:24113` (the well-connected node that was delivering blocks) repeatedly disconnects with `ERROR: Proxy error: host unreachable` and `connection refused`. The daemon then has to wait for Tor to re-establish the hidden service. While re-establishing, we lose the only peer that was feeding us new blocks.
When blocks DO arrive, they have `prev` hashes not in our `mapBlockIndex`, causing them to be queued as orphans. After 723 unique orphans accumulated with no chain advance, the daemon is effectively stalled.
**Root cause:** Tor hidden service reliability for the main node. This is a network/deployment issue, not a Triangles code issue.
### Conclusion
- **Issue 1 fix is in main.cpp and working.** Sync is more resilient to fork peers.
- **Issue 2 cannot be fixed in the Triangles codebase.** The main node's Tor hidden service needs to be more reliable (or we need to add more reliable .onion peers to the seed list).
- **The 18 blk/s sustained rate is the actual ceiling** for this Tor peer set. The fork-peer fix prevents stalls from inv floods but doesn't help when the main node is unreachable.
### Recommended Next Steps (beyond code)
1. Add more reliable .onion peers to the seed list in `seeds.cryptographic-triangles.org`
2. Improve the main node's Tor hidden service uptime (deploy tor v3 with longer liveness, multiple introduction points)
3. Add a peer-scoring system that downgrades flaky peers and prefers reliable ones
These are operational improvements, not code changes.
---
## Addendum (2026-06-21, end-of-day): Corrupted .onion Address & Signed Peer Discovery
After the above audit was written, two more findings emerged that warrant
their own section.
### Finding 8: Corrupted v3 onion address in test config (real bug, production-safe)
**Symptom:** During the running from-zero sync test (PID 2394385), the
embedded Tor log at `/root/.triangles-synctest/tor_data/tor.log` produced:
4,842 occurrences of: "Closed streams for service [scrubbed].onion for reason resolve failed. Fetch status: No more HSDir available to query."
181 occurrences of: "ed25519 validation failed"
181 occurrences of: "Service address [scrubbed] has bad pubkey"
181 occurrences of: "Invalid onion hostname [scrubbed]; rejecting"
The first instinct was "Tor is broken" — but the same Tor instance
worked fine for clearnet (`https://check.torproject.org/api/ip` returned
`{"IsTor":true,"IP":"192.42.116.60"}`) and for known .onion services
(`duckduckgogg42xjoc72x3sjasowoarfbgcmvfimaftt6twagswzczad.onion`
returned HTTP 301 in 3.5s).
**Root cause:** One of the 14 addnodes in `/root/.triangles-synctest/triangles.conf`
had a 1-character transposition:
| Source | Address |
|---|---|
| `src/onionseed.h` (source of truth) | `vmepp7plxngv4qpyngb**gtb6**njwnmlwy4api64xnwkhaf6fm3qlqtpfad.onion` |
| `/root/.triangles/triangles.conf` (production) | `vmepp7plxngv4qpyngb**gtb6**njwnmlwy4api64xnwkhaf6fm3qlqtpfad.onion` ✓ |
| `/root/.triangles-synctest/triangles.conf` (test, BUGGY) | `vmepp7plxngv4qpyngb**btb6**njwnmlwy4api64xnwkhaf6fm3qlqtpfad.onion` ✗ |
The character `g` was corrupted to `b` at position 21. Tor's v3 onion
checksum validation (`SHA3-256(".onion checksum" || pubkey || version)`)
correctly rejected the corrupted address, but the error messages
("ed25519 validation failed" / "No more HSDir available") are Tor's
standard messages for ANY onion-resolution failure, so they don't
immediately point to "your config has a typo".
**Why this matters more than the immediate symptom:**
This is exactly the kind of silent corruption that a signed peer
discovery system would catch at the daemon layer. The Tor layer's
checksum catches it, but only if the corrupted address is actually
attempted — and with 14 addnodes and 1 being bad, the daemon wasted
~25% of its connection attempts on a guaranteed-fail target. A signed
peer system (where peers' .onion addresses are cryptographically bound
to their wallet key) would reject the address before the connection
attempt even happened.
**Fixes deployed:**
1. **One-character config fix** in `/root/.triangles-synctest/triangles.conf`:
`btb6``gtb6`. Production was never affected.
2. **New tool: `scripts/validate_onion_seeds.py`** — validates every
`.onion` in a `triangles.conf` against the v3 hidden service checksum.
Detects the `btb6` corruption in 0.1s with full diagnostic including
"did you mean: gtb6?" suggestion. Pure stdlib, no pip deps.
3. **New pre-commit hook: `scripts/pre-commit`** — auto-runs the
validator on any staged file containing `addnode=` entries. Blocks
the commit if any address fails. Installed at
`.git/hooks/pre-commit`. Bypass with `git commit --no-verify` (NEVER
do this for normal commits).
4. **New C++ test: `src/test/onion_v3_tests.cpp`** — 8 Boost.Test cases
that validate every hardcoded seed in `src/onionseed.h` against the
v3 onion checksum. Runs in CI on every build. Catches corruption at
compile time, not daemon runtime.
### Finding 9: Signed peer discovery (real architectural improvement)
The above finding surfaced a bigger gap: Triangles HAS a node-identity
signing system (`getwalletaddr`/`walletaddr` in `src/tor/onion_v3.cpp:4793-4848`)
but it only fires at startup. After 18 hours of sync, the daemon has
zero ability to find new peers.
**The existing system (already in place, just under-used):**
1. **Node identity proof** (`main.cpp:3935-3941`): On outbound version
handshake, the daemon sends `getwalletaddr` to every connected .onion
peer. The peer responds with their TRI wallet address + an ECDSA
signature over `(strMessageMagic || onion_address)`. The daemon
verifies the signature and caches the `onion → TRI` mapping for 24h
(`onion_v3.cpp:2308`).
2. **Seeder list exchange** (`main.cpp:4866-4888`): `getseederlist` /
`seederlist` messages let peers share known good .onion seeders.
3. **Standard `getaddr`/`addr`** (`main.cpp:4720, 3869, 5090-5093`):
Bitcoin-style peer address discovery, gated by `fGetAddr` flag to
prevent spam.
**The fix shipped in commit `9e9d17e`:**
1. **`src/net.h`** — added `nLastGetaddrTrigger` + `nSignedPeerBonus`
fields to `CNode`.
2. **`src/net.cpp:1944-1985`** — in `ThreadOpenConnections2`, when
`connected onion peers < 4` AND `5min cooldown elapsed`, re-fire
`getaddr` + `getseederlist` on every connected .onion peer. Logs
`SYNC-SIGN: low peer count (X < 4), re-firing discovery round on all peers`.
3. **`src/tor/onion_v3.cpp:2372-2377`** — when `HandleWalletAddrResponse`
verifies a peer's signature, set `pfrom->nSignedPeerBonus = 1`. Logs
`SYNC-SIGN: marked X as signed peer (proved identity via walletaddr)`.
4. **`src/syncmanager.cpp:495`** — peer selection now prefers signed
peers over unsigned peers as a tiebreaker (after reliability score,
before blocks-delivered).
**Verified at runtime:**
SYNC-SIGN: low peer count (0 < 4), re-firing discovery round on all peers
SYNC-SIGN: low peer count (1 < 4), re-firing discovery round on all peers
SYNC-SIGN: marked X as signed peer (proved identity via walletaddr)
The signed peer bonus means that once a peer completes the walletaddr
handshake, they're preferred in block delivery — making the network
self-strengthening: nodes that prove identity get more traffic, which
incentivizes more nodes to prove identity.
### Defense-in-depth summary (end of 2026-06-21)
The from-zero sync test, the corruption bug, and the signed-peer
improvement together produced 4 layers of defense against the same
class of problem (peer discovery / address corruption):
| Layer | Mechanism | What it catches | When |
|---|---|---|---|
| 1. Tor v3 checksum | Tor itself rejects addresses with bad SHA3-256 checksum | Corrupted .onion addresses | Always (network layer) |
| 2. `scripts/validate_onion_seeds.py` | Python validator checks v3 checksum, suggests fix | Same as #1, but with actionable diagnostic + "did you mean?" | Pre-commit / pre-deploy |
| 3. `src/test/onion_v3_tests.cpp` | 8 Boost.Test cases run in CI | Hardcoded seed corruption in `onionseed.h` | Every build |
| 4. Signed peer discovery | `getwalletaddr` ECDSA handshake + `nSignedPeerBonus` preference | Sybil attackers + ephemeral malicious peers | At runtime |
### Remaining gaps (2026-06-21)
1. **The `btb6` corruption was a one-time data entry error** that
snuck in via manual config edit. There's no audit log of when/who
introduced it. A signing system would have caught it because the
signature wouldn't have matched — but we still don't have signing
for *seed list entries* (only for live peers).
2. **The seed list at `seeds.cryptographic-triangles.org` is not
cryptographically signed.** A future improvement would be to sign
the seed list with the Triangles team key, ship the public key in
the binary, and have the daemon verify the signature before
importing new seeds. This is the same pattern Bitcoin Core uses
for its `chainparams.cpp` checkpoints.
3. **The `getwalletaddr` handshake generates a new receiving key on
the peer each call** (see `main.cpp:4814: pwalletMain->GetKeyFromPool`).
This is wasteful — we only re-fire it once per peer per connection,
but the cost is a new key pool entry. Future work: use a stable
node identity key separate from the wallet.
+88
View File
@@ -0,0 +1,88 @@
# triangles.conf.example — Cryptographic Triangles daemon configuration
#
# Copy this to ~/.triangles/triangles.conf and customize for your node.
# Run scripts/validate_onion_seeds.py against your config before starting
# the daemon to catch any .onion address corruption.
#
# Run order for a fresh operator:
# 1. cp contrib/triangles.conf.example ~/.triangles/triangles.conf
# 2. Edit credentials, port numbers, addnode list as needed
# 3. python3 scripts/validate_onion_seeds.py ~/.triangles/triangles.conf
# 4. /usr/lib/cryptographic-triangles/trianglesd -daemon
#
# The pre-commit hook at scripts/pre-commit will auto-validate this file
# on every commit if you install it via:
# cp scripts/pre-commit .git/hooks/pre-commit && chmod +x .git/hooks/pre-commit
# ─── Network ─────────────────────────────────────────────────────────────────
# port=24112 is the mainnet P2P default. Pick an alternate (e.g. 24118) for
# test/parallel nodes to avoid clashing with production.
port=24112
listen=1
discover=1
# ─── RPC ─────────────────────────────────────────────────────────────────────
# Bind RPC to localhost only. The triangles-cli tool connects here.
rpcuser=trianglesrpc
rpcpassword=CHANGE_ME_TO_A_STRONG_RANDOM_PASSWORD
rpcport=19112
rpcallowip=127.0.0.1
server=1
# ─── Tor (MANDATORY — Triangles is Tor-only) ─────────────────────────────────
# Triangles peers are exclusively .onion addresses. Never use clearnet IPs
# in addnode= entries. See:
# * src/onionseed.h — hardcoded seed list (source of truth)
# * src/test/onion_v3_tests.cpp — validates the hardcoded list at CI
# * scripts/validate_onion_seeds.py — validates your config at pre-commit
#
# proxy= can point at:
# * Embedded Tor: 127.0.0.1:19099 (started automatically by the daemon)
# * System Tor: 127.0.0.1:9050
# * Tor Browser: 127.0.0.1:9150
proxy=127.0.0.1:19099
# ─── Hardcoded seed nodes (src/onionseed.h, v3 onion only) ──────────────────
# These 7 are the source-of-truth seeds. The C++ test suite validates
# every one of them at build time.
addnode=gxvrhv3qitnc6kobrhsrse46bmcfitnybapor3or3oczzuxn6hfzxyid.onion:24112
addnode=i6tk7soznftvoibtskwlezviskiererhjndpsmrff4kaxw7jnd5izfqd.onion:24112
addnode=nawqqoazk2hhaglygulpeg6kh7hsgnvi2fursdvpvkantu4ojj26taid.onion:24112
addnode=vmepp7plxngv4qpyngbgtb6njwnmlwy4api64xnwkhaf6fm3qlqtpfad.onion:24112
addnode=nsldmfujkiwsfha42ajp5zx7gz3ekwdk4nvowdpf56mayuxnzshuykqd.onion:24112
addnode=on4noksywc7b6cdbbxsp535l7j4cugunvlyz3iyhf6sfcg2qzaoy3eqd.onion:24112
addnode=3uyzltm5cy7xzunncp3d7ariw75erabdnj4l3cxwvsxb6h4orc7eiqad.onion:24112
# ─── Dynamic seeds (fetched from seeds.cryptographic-triangles.org) ─────────
# These are populated at runtime by the daemon from the HTTP seed list. You
# can also pin them here as a fallback for offline operation. They MUST be
# valid v3 onions — validate with scripts/validate_onion_seeds.py.
# addnode=6ygpphp2qsucwvhwefv6h6ehvk6zjf7b7zdp4ggkzjjwe76cg6jwm7id.onion:24112
# addnode=uddaxjbo3lh2zskg7w6gwln4ty5cel7q4c5jbx7fdtv6zf2j47gdlyad.onion:24112
# addnode=el5sirhhleecuctpeeprelzubpqmoqivvra3rzlwbjttinxa4fq3wnid.onion:24112
# addnode=sj5dhybnlp3v4y5niyc5unrnd6s43lyx5ibup7rolyosjbi2u2hsbvyd.onion:24112
# addnode=i3kr5meha7se4ns3wss3h7v46m6uksfzv4wrohdqxpj6n35wyo2bvlid.onion:24112
# addnode=odtiwh6d2mqweztjrp45g5ogf4ikwtl5gotpjcbtax2qzkztrqcqieid.onion:24112
# addnode=jbpfhe7zw3qm67wy3j2ayysp3mnrjobopthnko3b3sgahqtecblwqmid.onion:24112
# ─── Indexes ─────────────────────────────────────────────────────────────────
# Required for getaddressbalance / getaddressutxos / getaddresstxids RPCs
# and for the bootstrap server to serve UTXO snapshots. Costs ~5GB disk.
txindex=1
addressindex=1
spentindex=1
timestampindex=1
# ─── Staking ─────────────────────────────────────────────────────────────────
# Set staking=0 to disable stake mining (recommended for sync-test / archive
# nodes that don't need to produce blocks).
staking=1
stakegen=1
# ─── Performance ────────────────────────────────────────────────────────────
# dbcache in MB. 512 is reasonable for sync nodes. 1024+ for archival nodes.
dbcache=512
# ─── Security ───────────────────────────────────────────────────────────────
# Disable Tor — DO NOT REMOVE THIS. Triangles is Tor-only by design.
notor=0
+30
View File
@@ -0,0 +1,30 @@
pkgbase = triangles-qt-bin
pkgdesc = Cryptographic Triangles (TRI) cryptocurrency wallet - Qt GUI
pkgver = 5.9.20
pkgrel = 1
url = https://cryptographic-triangles.org
arch = x86_64
license = MIT
depends = qt5-base
depends = openssl
depends = boost-libs
depends = db
depends = leveldb
depends = libevent
depends = miniupnpc
depends = tor
optdepend = tor: anonymous networking support
provides = triangles-qt
provides = trianglesd
provides = triangles-cli
conflicts = triangles-qt
conflicts = trianglesd
conflicts = triangles-cli
source = https://github.com/SamiAhmed7777/triangles_v5/releases/download/v5.9.20/cryptographic-triangles_5.9.20_amd64.deb
source = https://github.com/SamiAhmed7777/triangles_v5/releases/download/v5.9.20/cryptographic-triangles-daemon_5.9.20_amd64.deb
source = triangles-qt.desktop
sha256sums = b4afcf758f55c8fb256f4742917971414078ce37c0fe346383ccda5251917bde
sha256sums = 068d015cf73206f3f3604b0c8fbf60db307c20234cbe06e236996fb9a336df51
sha256sums = SKIP
pkgname = triangles-qt-bin
+57 -14
View File
@@ -1,6 +1,6 @@
# Maintainer: Cryptographic Triangles Team
# Maintainer: Sami Ahmed <https://github.com/SamiAhmed7777>
pkgname=triangles-qt-bin
pkgver=5.5.6
pkgver=5.9.20
pkgrel=1
pkgdesc="Cryptographic Triangles (TRI) cryptocurrency wallet - Qt GUI"
arch=('x86_64')
@@ -8,21 +8,64 @@ url="https://cryptographic-triangles.org"
license=('MIT')
depends=('qt5-base' 'openssl' 'boost-libs' 'db' 'leveldb' 'libevent' 'miniupnpc' 'tor')
optdepends=('tor: anonymous networking support')
provides=('triangles-qt' 'trianglesd')
conflicts=('triangles-qt' 'trianglesd')
provides=('triangles-qt' 'trianglesd' 'triangles-cli')
conflicts=('triangles-qt' 'trianglesd' 'triangles-cli')
source=(
"triangles-qt-${pkgver}::https://github.com/SamiAhmed7777/triangles_v5/releases/download/v${pkgver}/Cryptographic-Triangles-v${pkgver}-linux-x64-qt"
"trianglesd-${pkgver}::https://github.com/SamiAhmed7777/triangles_v5/releases/download/v${pkgver}/Cryptographic-Triangles-v${pkgver}-linux-x64-daemon"
"https://github.com/SamiAhmed7777/triangles_v5/releases/download/v${pkgver}/cryptographic-triangles_${pkgver}_amd64.deb"
"https://github.com/SamiAhmed7777/triangles_v5/releases/download/v${pkgver}/cryptographic-triangles-daemon_${pkgver}_amd64.deb"
"triangles-qt.desktop"
)
sha256sums=(
'ed220eb8d0b403f62cdac28988541fd1a27864491e233216f9c00a4c2537b4a3'
'4d2ab25d61127d6aff3e6f3069556d04f4b823f8849e97629c12871ad4779517'
'SKIP'
)
sha256sums=('b4afcf758f55c8fb256f4742917971414078ce37c0fe346383ccda5251917bde'
'068d015cf73206f3f3604b0c8fbf60db307c20234cbe06e236996fb9a336df51'
'SKIP')
prepare() {
cd "$srcdir"
# Qt GUI + bundled Qt/libs come from the full wallet .deb
ar x "cryptographic-triangles_${pkgver}_amd64.deb"
tar --use-compress-program=unzstd -xf data.tar.zst
rm -f control.tar.zst data.tar.zst debian-binary
# Headless daemon + JSON-RPC client come from the daemon .deb
ar x "cryptographic-triangles-daemon_${pkgver}_amd64.deb"
tar --use-compress-program=unzstd -xf data.tar.zst
rm -f control.tar.zst data.tar.zst debian-binary
}
package() {
install -Dm755 "triangles-qt-${pkgver}" "${pkgdir}/usr/bin/triangles-qt"
install -Dm755 "trianglesd-${pkgver}" "${pkgdir}/usr/bin/trianglesd"
install -Dm644 "triangles-qt.desktop" "${pkgdir}/usr/share/applications/triangles-qt.desktop"
cd "$srcdir"
# Install the actual binaries to /opt/triangles
install -dm755 "${pkgdir}/opt/triangles"
install -m755 usr/lib/cryptographic-triangles/triangles-qt \
"${pkgdir}/opt/triangles/triangles-qt"
install -m755 usr/lib/cryptographic-triangles/trianglesd \
"${pkgdir}/opt/triangles/trianglesd"
install -m755 usr/lib/cryptographic-triangles/triangles-cli \
"${pkgdir}/opt/triangles/triangles-cli"
# Install bundled shared libraries to /opt/triangles/lib.
# Many are version-pinned (librocksdb.so.6.11, libgflags.so.2.2,
# libdb_cxx-5.3.so, libboost_program_options.so.1.74.0) and are not
# available at the right version on Arch, so we ship them ourselves.
install -dm755 "${pkgdir}/opt/triangles/lib"
# Use GUI .deb libs (it has the full Qt set + everything daemon needs)
install -m644 usr/lib/cryptographic-triangles/lib/* \
"${pkgdir}/opt/triangles/lib/"
# Wrapper scripts in /usr/bin set LD_LIBRARY_PATH and exec the real binary.
# System Qt5/openssl/etc. are still on the default loader path and take
# precedence for libs NOT in our private directory.
install -dm755 "${pkgdir}/usr/bin"
for bin in triangles-qt trianglesd triangles-cli; do
install -m755 /dev/stdin "${pkgdir}/usr/bin/${bin}" <<EOF
#!/bin/bash
export LD_LIBRARY_PATH=/opt/triangles/lib\${LD_LIBRARY_PATH:+:\${LD_LIBRARY_PATH}}
exec /opt/triangles/${bin} "\$@"
EOF
done
# .desktop file
install -Dm644 triangles-qt.desktop \
"${pkgdir}/usr/share/applications/triangles-qt.desktop"
}
@@ -1,18 +1,14 @@
$ErrorActionPreference = 'Stop'
$packageArgs = @{
packageName = 'triangles'
unzipLocation = "$(Split-Path -Parent $MyInvocation.MyCommand.Definition)"
url64bit = 'https://github.com/SamiAhmed7777/triangles_v5/releases/download/v5.3.7/Cryptographic-Triangles-5.3.7-win-x64.zip'
checksum64 = '6f002a669a7e92aaf3d8dd7b1ae80f06a086c99a15ca05cf107665009ffc06b7'
packageName = $env:ChocolateyPackageName
fileType = 'exe'
softwareName = 'Cryptographic Triangles*'
url64bit = "https://github.com/SamiAhmed7777/triangles_v5/releases/download/v$env:ChocolateyPackageVersion/Cryptographic-Triangles-$env:ChocolateyPackageVersion-win-x64-setup.exe"
checksum64 = '__CHECKSUM_PLACEHOLDER__'
checksumType64 = 'sha256'
silentArgs = '/S'
validExitCodes = @(0, 3010, 1641)
}
Install-ChocolateyZipPackage @packageArgs
$installDir = $packageArgs.unzipLocation
$desktopPath = [Environment]::GetFolderPath('Desktop')
Install-ChocolateyShortcut `
-ShortcutFilePath "$desktopPath\Cryptographic Triangles.lnk" `
-TargetPath "$installDir\triangles-qt.exe"
Install-ChocolateyPackage @packageArgs
+41 -23
View File
@@ -1,39 +1,57 @@
FROM ubuntu:22.04 AS builder
ARG VERSION=5.9.20
ARG DEB_URL=https://github.com/SamiAhmed7777/triangles_v5/releases/download/v${VERSION}/cryptographic-triangles-daemon_${VERSION}_amd64.deb
RUN apt-get update && apt-get install -y --no-install-recommends \
curl ca-certificates binutils zstd && \
curl -fsSL -o /tmp/triangles.deb "${DEB_URL}" && \
cd /tmp && ar x /tmp/triangles.deb && \
tar --use-compress-program=unzstd -xf data.tar.zst && \
rm -f /tmp/triangles.deb /tmp/control.tar.zst /tmp/debian-binary /tmp/data.tar.zst
# ---------- Runtime ----------
FROM ubuntu:22.04
ARG VERSION=5.9.20
LABEL maintainer="Cryptographic Triangles Team"
LABEL description="Cryptographic Triangles (TRI) headless daemon"
LABEL version="5.7.6"
ARG VERSION=5.7.6
LABEL version="${VERSION}"
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
ca-certificates \
libssl3 \
libevent-2.1-7 \
libboost-system1.74.0 \
libboost-filesystem1.74.0 \
libboost-program-options1.74.0 \
libboost-thread1.74.0 \
libboost-chrono1.74.0 \
libdb5.3++ \
libminiupnpc17 \
tor \
ca-certificates \
libssl3 \
libevent-2.1-7 \
libboost-system1.74.0 \
libboost-filesystem1.74.0 \
libboost-program-options1.74.0 \
libboost-thread1.74.0 \
libboost-chrono1.74.0 \
libdb5.3++ \
libminiupnpc17 \
&& rm -rf /var/lib/apt/lists/*
RUN curl -L -o /usr/local/bin/trianglesd \
"https://github.com/SamiAhmed7777/triangles_v5/releases/download/v${VERSION}/Cryptographic-Triangles-v${VERSION}-linux-x64-daemon" \
&& chmod +x /usr/local/bin/trianglesd
COPY --from=builder /tmp/usr/lib/cryptographic-triangles/ /opt/triangles/
COPY --from=builder /tmp/usr/bin/trianglesd /usr/local/bin/trianglesd
COPY --from=builder /tmp/usr/bin/triangles-cli /usr/local/bin/triangles-cli
RUN useradd -m -s /bin/bash triangles
# Wrapper sets LD_LIBRARY_PATH so the dynamic libs resolve
RUN printf '#!/bin/bash\nexport LD_LIBRARY_PATH=/opt/triangles/lib:${LD_LIBRARY_PATH}\nexec /opt/triangles/%s "$@"\n' trianglesd \
> /usr/local/bin/trianglesd-wrap && \
printf '#!/bin/bash\nexport LD_LIBRARY_PATH=/opt/triangles/lib:${LD_LIBRARY_PATH}\nexec /opt/triangles/%s "$@"\n' triangles-cli \
> /usr/local/bin/triangles-cli-wrap && \
mv /usr/local/bin/trianglesd-wrap /usr/local/bin/trianglesd && \
mv /usr/local/bin/triangles-cli-wrap /usr/local/bin/triangles-cli && \
chmod +x /usr/local/bin/trianglesd /usr/local/bin/triangles-cli
RUN useradd -m -s /bin/bash triangles && \
mkdir -p /home/triangles/.triangles && \
chown -R triangles:triangles /home/triangles
USER triangles
WORKDIR /home/triangles
RUN mkdir -p /home/triangles/.triangles
VOLUME /home/triangles/.triangles
EXPOSE 24112 19112
ENTRYPOINT ["trianglesd"]
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env bash
# .git/hooks/pre-commit — Cryptographic Triangles
#
# Auto-runs scripts/validate_onion_seeds.py against any staged file that
# contains .onion addresses. Blocks the commit if any address fails v3
# onion checksum validation.
#
# This is the primary defense against the "1-character .onion transposition
# bug" that caused 4,842 Tor "No more HSDir" errors during the 2026-06-21
# from-zero sync test. See scripts/validate_onion_seeds.py for the validator
# and references/sync-security-audit-2026-06-21.md for the full story.
#
# The hook scans staged files for two patterns:
# 1. Filename matches: triangles.conf, *.onion
# 2. Content contains addnode= entries with .onion addresses
#
# To install:
# cp scripts/pre-commit .git/hooks/pre-commit
# chmod +x .git/hooks/pre-commit
#
# To bypass (in emergencies only — NEVER do this for normal commits):
# git commit --no-verify
set -e
REPO_ROOT="$(git rev-parse --show-toplevel)"
VALIDATOR="${REPO_ROOT}/scripts/validate_onion_seeds.py"
# Find the validator
if [[ ! -x "$VALIDATOR" ]]; then
echo "pre-commit: WARNING: $VALIDATOR not found or not executable" >&2
echo "pre-commit: skipping v3 onion validation" >&2
echo "pre-commit: install with: chmod +x $VALIDATOR" >&2
exit 0
fi
# Two-pass detection:
# Pass 1: filename-based — files named triangles.conf or *.onion
# Pass 2: content-based — any file containing "addnode=" + .onion address
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACMR)
# Pass 1: filename-based
NAME_MATCHES=$(echo "$STAGED_FILES" | grep -E '(triangles\.conf$|\.onion$)' || true)
# Pass 2: content-based — find staged files containing addnode= with .onion addresses
CONTENT_MATCHES=""
for f in $STAGED_FILES; do
if [[ -f "$f" ]] && grep -qE '^[[:space:]]*addnode=[a-z2-7]{56}\.onion' "$f" 2>/dev/null; then
CONTENT_MATCHES="$CONTENT_MATCHES $f"
fi
done
# Combine and dedupe
ALL_MATCHES=$(printf "%s\n%s\n" "$NAME_MATCHES" "$CONTENT_MATCHES" | sort -u | grep -v '^$' || true)
if [[ -z "$ALL_MATCHES" ]]; then
# Nothing to validate
exit 0
fi
# Filter to only files that exist (skip deletions)
EXISTING_CONFIGS=""
for f in $ALL_MATCHES; do
if [[ -f "$f" ]]; then
EXISTING_CONFIGS="$EXISTING_CONFIGS $f"
fi
done
if [[ -z "$EXISTING_CONFIGS" ]]; then
exit 0
fi
COUNT=$(echo $EXISTING_CONFIGS | wc -w)
echo "pre-commit: validating $COUNT staged file(s) with .onion addresses..."
# Build the validator command
CMD="python3 \"$VALIDATOR\" --no-color --ci"
if [[ -f "${REPO_ROOT}/src/onionseed.h" ]]; then
CMD="$CMD --against \"${REPO_ROOT}/src/onionseed.h\""
fi
# Run the validator
if eval $CMD $EXISTING_CONFIGS; then
echo "pre-commit: v3 onion validation PASSED"
exit 0
else
EXIT_CODE=$?
echo "" >&2
echo "pre-commit: v3 onion validation FAILED (exit $EXIT_CODE)" >&2
echo "" >&2
echo " The commit was blocked because one or more .onion addresses failed" >&2
echo " v3 hidden service checksum validation. This means the .onion address" >&2
echo " has a typo or character transposition that Tor will reject at runtime" >&2
echo " with 'ed25519 validation failed' / 'No more HSDir available to query'." >&2
echo "" >&2
echo " Fix the .onion address in the affected file, then re-stage and commit." >&2
echo "" >&2
echo " To inspect the failure in detail, run manually:" >&2
echo " python3 $VALIDATOR --against ${REPO_ROOT}/src/onionseed.h \\" >&2
echo " $EXISTING_CONFIGS" >&2
echo "" >&2
echo " To bypass this check (DO NOT do this for normal commits):" >&2
echo " git commit --no-verify" >&2
exit 1
fi
+391
View File
@@ -0,0 +1,391 @@
#!/usr/bin/env python3
"""
validate_onion_seeds.py - Cryptographic Triangles v3 onion address validator
Validates every .onion address in a triangles.conf (or any text file) against
the v3 hidden service checksum algorithm:
v3 onion = base32( version[2] || pubkey[32] || checksum[2] )
where checksum = SHA3-256( ".onion checksum" || version || pubkey )[:2]
and version = 0x03 0x00
A corrupted v3 onion (e.g. one character transposed) will have a valid base32
shape but a failing checksum. Tor rejects these with:
[warn] ed25519 validation failed
[warn] Service address has bad pubkey
[warn] Invalid onion hostname; rejecting
[notice] ... resolve failed. No more HSDir available to query.
This tool is designed to be run as a pre-flight check before deploying
a triangles.conf, and as a CI gate to prevent corrupted .onion addresses
from ever reaching production. It can also be used to audit an existing
config for inconsistencies against the hardcoded seed list in
src/onionseed.h.
USAGE
# Validate the production config
./validate_onion_seeds.py /root/.triangles/triangles.conf
# Validate multiple configs
./validate_onion_seeds.py /root/.triangles/triangles.conf \\
/root/.triangles-synctest/triangles.conf
# Audit a config against the hardcoded source-of-truth
./validate_onion_seeds.py /root/.triangles/triangles.conf \\
--against /root/triangles_v5/src/onionseed.h
# CI mode (exit 1 on any error)
./validate_onion_seeds.py /root/.triangles/triangles.conf --ci
EXIT CODES
0 all addresses valid, no warnings
1 one or more addresses failed validation
2 usage error / file not found
DETECTION CAPABILITIES
* Bad v3 checksum (1-2 char transposition, missing char, etc.)
* Truncated or extended .onion addresses
* Non-base32 characters in .onion
* Cross-config diff (or test vs production mismatch)
* addnode referencing a .onion that's not in the source seed list
BACKGROUND
During a from-zero sync test on 2026-06-21, the test daemon's Tor log
produced 4,842 "No more HSDir available" errors and 181 "ed25519
validation failed" warnings. Root cause: a 1-character transposition
(btb6 vs gtb6) in the test config's vmepp seed address. This tool
would have caught it in 0.1 seconds.
"""
import argparse
import base64
import hashlib
import os
import re
import sys
from pathlib import Path
# v3 onion constants
V3_VERSION = b'\x03\x00' # 2 bytes
V3_CHECKSUM_INPUT = b'.onion checksum' # 15 bytes
V3_PUBKEY_LENGTH = 32
V3_CHECKSUM_LENGTH = 2
V3_DECODED_LENGTH = 35 # 2 + 32 + 2 + ...wait that's 36
# Actually v3 onion base32-decodes to 35 bytes:
# 1 byte version (0x03) + 1 byte checksum-type (0x00) +
# 32 bytes pubkey + 2 bytes checksum -- no wait
# Per official spec: onion_address = base32(pubkey || checksum || version)
# Total = 32 (ed25519) + 2 (checksum) + 1 (version) = 35 bytes
# But some implementations use:
# version(2) || pubkey(32) || checksum(2) = 36
# The actual spec from rfc7686 says:
# onion_address = base32(PUBKEY || CHECKSUM || VERSION)
# PUBKEY = ed25519 public key (32 bytes)
# CHECKSUM = H(".onion checksum" || PUBKEY || VERSION)[:2]
# VERSION = 0x03
# So total = 32 + 2 + 1 = 35 bytes (not 36)
# We'll use the official spec (35 bytes)
# ANSI color codes (only if stdout is a TTY)
class C:
RESET = '\033[0m'
RED = '\033[91m'
GREEN = '\033[92m'
YELLOW = '\033[93m'
BLUE = '\033[94m'
BOLD = '\033[1m'
DIM = '\033[2m'
@classmethod
def disable(cls):
for attr in dir(cls):
if attr.isupper() and not attr.startswith('_'):
setattr(cls, attr, '')
def decode_v3_onion(address: str) -> tuple[bool, str, bytes | None]:
"""
Validate a v3 onion address.
Returns:
(valid, reason, decoded_bytes_or_None)
"""
if not isinstance(address, str):
return False, f"not a string (got {type(address).__name__})", None
if not address.endswith('.onion'):
return False, "missing .onion suffix", None
onion_body = address[:-6] # strip .onion
expected_len = 56 # base32(35 bytes) = 56 chars
if len(onion_body) != expected_len:
return False, f"wrong length: {len(onion_body)} chars (expected {expected_len})", None
# Validate base32 alphabet
if not re.match(r'^[a-z2-7]+$', onion_body):
# Find first bad char
for i, c in enumerate(onion_body):
if not re.match(r'[a-z2-7]', c):
return False, f"non-base32 char '{c}' at position {i}", None
# Decode
try:
# Add padding
padding_needed = (8 - len(onion_body) % 8) % 8
decoded = base64.b32decode(onion_body.upper() + '=' * padding_needed)
except Exception as e:
return False, f"base32 decode failed: {e}", None
if len(decoded) != 35:
return False, f"decoded to {len(decoded)} bytes, expected 35", None
# v3 spec: PUBKEY(32) || CHECKSUM(2) || VERSION(1)
pubkey = decoded[0:32]
checksum = decoded[32:34]
version = decoded[34:35]
if version != b'\x03':
return False, f"version byte is 0x{version[0]:02x}, expected 0x03", decoded
# Compute expected checksum
expected_checksum = hashlib.sha3_256(
V3_CHECKSUM_INPUT + pubkey + version
).digest()[:2]
if checksum != expected_checksum:
return False, (
f"checksum mismatch: got 0x{checksum.hex()}, "
f"expected 0x{expected_checksum.hex()}"
), decoded
return True, "valid v3 onion", decoded
def parse_config_addnodes(config_path: Path) -> list[tuple[str, str, int]]:
"""
Extract (line_no, address, port) tuples for all addnode= lines in a config.
Also handles addnode=onion:port and just addnode=onion (port defaults to 24112).
"""
addnodes = []
if not config_path.exists():
return addnodes
for line_no, raw_line in enumerate(config_path.read_text().splitlines(), 1):
line = raw_line.strip()
if not line or line.startswith('#'):
continue
m = re.match(r'^addnode=([^:]+)(?::(\d+))?$', line)
if m:
addr = m.group(1)
port = int(m.group(2)) if m.group(2) else 24112
addnodes.append((line_no, addr, port))
return addnodes
def parse_source_seeds(source_path: Path) -> set[str]:
"""
Extract all .onion addresses from the hardcoded seed list in onionseed.h.
Matches the strMainNetOnionSeed and strTestNetOnionSeed arrays.
"""
seeds = set()
if not source_path.exists():
return seeds
for m in re.finditer(r'"([a-z2-7]{56}\.onion)"', source_path.read_text()):
seeds.add(m.group(1))
return seeds
def levenshtein_1(a: str, b: str) -> int:
"""Return number of positions where a and b differ (assumes same length)."""
if len(a) != len(b):
return -1
return sum(1 for x, y in zip(a, b) if x != b.count(x))
def find_near_match(target: str, candidates: set[str]) -> str | None:
"""Find a candidate that's 1-2 char different from target (for diff hints)."""
for c in candidates:
if len(c) == len(target):
d = sum(1 for x, y in zip(c, target) if x != y)
if 0 < d <= 2:
return c
return None
def colorize(s: str, color: str, enabled: bool) -> str:
return f"{color}{s}{C.RESET}" if enabled else s
def validate_config(
config_path: Path,
source_seeds: set[str] | None = None,
other_configs: dict[Path, set[str]] | None = None,
use_color: bool = True,
) -> tuple[int, int, int, int]:
"""
Validate all .onion addresses in a config file.
Returns:
(valid_count, invalid_count, missing_count, extra_count)
"""
addnodes = parse_config_addnodes(config_path)
if not addnodes:
print(colorize(f" (no addnode= entries found in {config_path})",
C.YELLOW, use_color))
return (0, 0, 0, 0)
valid = invalid = 0
invalid_addrs = set()
print(colorize(f"\n=== {config_path} ===", C.BOLD + C.BLUE, use_color))
print(colorize(f" {len(addnodes)} addnode entries found", C.DIM, use_color))
for line_no, addr, port in addnodes:
ok, reason, _ = decode_v3_onion(addr)
if ok:
print(f" {colorize('[OK]', C.GREEN, use_color):>14} line {line_no:>4} {addr}")
valid += 1
else:
print(f" {colorize('[BAD]', C.RED, use_color):>14} line {line_no:>4} {addr}")
print(f" {'':<14} {'':>4} reason: {reason}")
# Try to suggest a similar address
if source_seeds:
near = find_near_match(addr, source_seeds)
if near:
print(f" {'':<14} {'':>4} {colorize(f'did you mean: {near}?', C.YELLOW, use_color)}")
invalid += 1
invalid_addrs.add(addr)
# Cross-check against other configs
missing = extra = 0
if other_configs and source_seeds is not None:
config_addrs = {addr for _, addr, _ in addnodes}
# Note: this just reports on relationships; doesn't fail the test
for other_path, other_addrs in other_configs.items():
only_in_this = config_addrs - other_addrs - invalid_addrs
only_in_other = other_addrs - config_addrs
if only_in_this:
print(colorize(
f"\n {colorize('[DIFF]', C.YELLOW, use_color)} addresses only in {config_path.name} "
f"(missing from {other_path.name}):",
C.YELLOW, use_color))
for a in sorted(only_in_this):
print(f" {a}")
extra += len(only_in_this)
if only_in_other:
print(colorize(
f"\n {colorize('[DIFF]', C.YELLOW, use_color)} addresses only in {other_path.name} "
f"(missing from {config_path.name}):",
C.YELLOW, use_color))
for a in sorted(only_in_other):
print(f" {a}")
missing += len(only_in_other)
return valid, invalid, missing, extra
def main():
parser = argparse.ArgumentParser(
description="Validate v3 .onion addresses in Triangles config files",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
parser.add_argument(
'configs',
nargs='+',
type=Path,
help='One or more triangles.conf files to validate',
)
parser.add_argument(
'--against',
type=Path,
default=None,
help='Path to src/onionseed.h to use as source of truth for diff hints',
)
parser.add_argument(
'--ci',
action='store_true',
help='CI mode: exit 1 if any address fails validation',
)
parser.add_argument(
'--no-color',
action='store_true',
help='Disable colored output (also auto-disabled when stdout is not a TTY)',
)
args = parser.parse_args()
# Color detection
use_color = not args.no_color and sys.stdout.isatty()
if not use_color:
C.disable()
# Validate inputs exist
for p in args.configs:
if not p.exists():
print(colorize(f"ERROR: file not found: {p}", C.RED, use_color),
file=sys.stderr)
return 2
# Load source seeds if provided
source_seeds = None
if args.against:
if not args.against.exists():
print(colorize(f"WARNING: source seed file not found: {args.against}",
C.YELLOW, use_color), file=sys.stderr)
else:
source_seeds = parse_source_seeds(args.against)
print(colorize(
f"Loaded {len(source_seeds)} hardcoded seeds from {args.against}",
C.DIM, use_color))
# Pre-load all configs for cross-checking
all_configs: dict[Path, set[str]] = {}
for p in args.configs:
addnodes = parse_config_addnodes(p)
all_configs[p] = {addr for _, addr, _ in addnodes}
# Validate each config
total_valid = total_invalid = total_missing = total_extra = 0
for p in args.configs:
if len(args.configs) > 1:
other = {k: v for k, v in all_configs.items() if k != p}
else:
other = None
v, i, m, e = validate_config(p, source_seeds, other, use_color)
total_valid += v
total_invalid += i
total_missing += m
total_extra += e
# Summary
print(colorize("\n=== SUMMARY ===", C.BOLD, use_color))
print(f" Valid: {colorize(str(total_valid), C.GREEN, use_color)}")
if total_invalid:
print(f" Invalid: {colorize(str(total_invalid), C.RED, use_color)}")
else:
print(f" Invalid: {total_invalid}")
if total_missing:
print(f" Missing: {colorize(str(total_missing), C.YELLOW, use_color)} "
f"(in other configs, not this one)")
if total_extra:
print(f" Extra: {colorize(str(total_extra), C.YELLOW, use_color)} "
f"(in this config, not others)")
if total_invalid == 0 and total_missing == 0:
print(colorize("\n All addresses valid.", C.GREEN + C.BOLD, use_color))
return 0
else:
print(colorize(
f"\n {total_invalid} address(es) failed v3 onion checksum validation.",
C.RED + C.BOLD, use_color))
if args.ci:
return 1
return 1 if total_invalid else 0
if __name__ == '__main__':
sys.exit(main())
+7 -130
View File
@@ -460,114 +460,6 @@ static int64_t ParseTarOctal(const char* field, size_t len)
}
// Extract a tar.gz file to a destination directory
static bool ExtractTarGz(const fs::path& tarGzPath,
const fs::path& destDir,
std::string& strError)
{
gzFile gz = gzopen(tarGzPath.string().c_str(), "rb");
if (!gz) {
strError = "Cannot open " + tarGzPath.string();
return false;
}
gzbuffer(gz, 262144); // 256 KB buffer for performance
char header[512];
while (true) {
int bytesRead = gzread(gz, header, 512);
if (bytesRead == 0) break; // EOF
if (bytesRead != 512) {
strError = "Truncated tar header";
gzclose(gz);
return false;
}
// End-of-archive marker (zero block)
bool allZero = true;
for (int i = 0; i < 512; i++) {
if (header[i] != 0) { allZero = false; break; }
}
if (allZero) break;
// Parse filename: name (offset 0, 100 bytes) + optional prefix (offset 345, 155 bytes)
char name[101] = {0};
char prefix[156] = {0};
memcpy(name, header, 100);
memcpy(prefix, header + 345, 155);
std::string fullName;
if (prefix[0] != '\0')
fullName = std::string(prefix) + "/" + std::string(name);
else
fullName = std::string(name);
// Security: reject absolute paths and path traversal
if (fullName.empty() || fullName[0] == '/' || fullName.find("..") != std::string::npos) {
strError = "Unsafe path in tar archive: " + fullName;
gzclose(gz);
return false;
}
char typeflag = header[156];
int64_t fileSize = ParseTarOctal(header + 124, 12);
if (typeflag == '5' || (!fullName.empty() && fullName.back() == '/')) {
// Directory entry
fs::create_directories(destDir / fullName);
} else if (typeflag == '0' || typeflag == '\0') {
// Regular file
fs::path filePath = destDir / fullName;
fs::create_directories(filePath.parent_path());
FILE* outFile = fopen(filePath.string().c_str(), "wb");
if (!outFile) {
strError = "Cannot create file: " + filePath.string();
gzclose(gz);
return false;
}
int64_t remaining = fileSize;
char buf[65536];
while (remaining > 0) {
int toRead = (remaining > (int64_t)sizeof(buf)) ? (int)sizeof(buf) : (int)remaining;
int n = gzread(gz, buf, toRead);
if (n <= 0) {
fclose(outFile);
strError = "Truncated tar data for: " + fullName;
gzclose(gz);
return false;
}
fwrite(buf, 1, n, outFile);
remaining -= n;
}
fclose(outFile);
// Skip padding to next 512-byte boundary
int64_t pad = (512 - (fileSize % 512)) % 512;
if (pad > 0) {
char padBuf[512];
if (gzread(gz, padBuf, (unsigned)pad) != (int)pad) {
strError = "Truncated tar padding for: " + fullName;
gzclose(gz);
return false;
}
}
} else {
// Unknown entry type - skip its data
int64_t totalSkip = fileSize + ((512 - (fileSize % 512)) % 512);
char skipBuf[512];
while (totalSkip > 0) {
int toRead = (totalSkip > 512) ? 512 : (int)totalSkip;
if (gzread(gz, skipBuf, toRead) != toRead) break;
totalSkip -= toRead;
}
}
}
gzclose(gz);
return true;
}
} // anonymous namespace
@@ -684,34 +576,19 @@ bool DownloadBootstrap(const std::string& host,
{
bool gotBlockFile = false;
// Try downloading bootstrap.tar.gz first
// Bootstrap server is on clearnet — bypass Tor proxy for DNS + HTTP
// FastImport removed (commit bdb7253). v2 UTXO snapshot is the ONLY
// supported sync path. Skip the legacy tarball fallback entirely so we
// never hit /triangles-bootstrap.tar.gz (404 since 2026-06-19 cleanup)
// or /tri-bootstrap.tar.gz (also gone; was the URL in the old filelist.txt).
// The remaining path below reads filelist.txt → downloads utxo-snapshot.bin.
const bool noProxy = true;
fs::path tmpTarGz = dataDir / "bootstrap.tar.gz.tmp";
std::string tarUrl = std::string(BASE_PATH) + "triangles-bootstrap.tar.gz";
printf("DownloadBootstrap(): attempting tar.gz download from %s%s\n", host.c_str(), tarUrl.c_str());
bool tarDownloaded = DownloadFile(host, tarUrl, tmpTarGz, progressFn, strError, noProxy);
printf("DownloadBootstrap(): tarDownloaded=%d result=%s\n", tarDownloaded, strError.c_str());
if (tarDownloaded) {
bool extractOk = ExtractTarGz(tmpTarGz, dataDir, strError);
fs::remove(tmpTarGz);
if (extractOk && fs::exists(dataDir / "blk0001.dat"))
gotBlockFile = true;
// If extraction failed, fall through to legacy path
}
if (!gotBlockFile) {
// Fallback: try filelist.txt + individual file downloads
// Try filelist.txt — should contain only utxo-snapshot.bin (v2).
std::string fallbackError;
std::vector<std::string> files;
if (!FetchFileList(host, files, fallbackError, noProxy)) {
if (!tarDownloaded)
strError = strError + " (fallback also failed: " + fallbackError + ")";
else
strError = "Extraction failed: " + strError + " (fallback also failed: " + fallbackError + ")";
strError = "filelist.txt unavailable: " + fallbackError;
return false;
}
+1 -1
View File
@@ -8,7 +8,7 @@
// These need to be macros, as version.cpp's and triangles-qt.rc's voodoo requires it
#define CLIENT_VERSION_MAJOR 5
#define CLIENT_VERSION_MINOR 9
#define CLIENT_VERSION_REVISION 20
#define CLIENT_VERSION_REVISION 23
#define CLIENT_VERSION_BUILD 0
// Converts the parameter X to a string after macro replacement on X has been performed.
+16
View File
@@ -506,6 +506,7 @@ std::string HelpMessage()
" -dbcache=<n> " + _("Set database cache size in megabytes (default: 25)") + "\n" +
" -dblogsize=<n> " + _("Set database disk log size in megabytes (default: 100)") + "\n" +
" -timeout=<n> " + _("Specify connection timeout in milliseconds (default: 5000)") + "\n" +
" -torconnecttimeout=<n> " + _("Max time (ms) for the SOCKS5 handshake with the Tor proxy (send+recv of SOCKS5 init/auth/connect). Bounds how long a dead/slow .onion can stall the connector thread (default: 60000, range 5000-180000)") + "\n" +
//" -proxy=<ip:port> " + _("Connect through socks proxy") + "\n" +
//" -socks=<n> " + _("Select the version of socks proxy to use (4-5, default: 5)") + "\n" +
" -tor=<ip:port> " + _("Use proxy to reach tor hidden services (default: same as -proxy)") + "\n"
@@ -780,6 +781,21 @@ bool AppInit2()
nConnectTimeout = nNewTimeout;
}
// SOCKS5/Tor negotiation timeout. Separate from -timeout (which only covers
// the instant local connect to the Tor SOCKS proxy); this bounds the
// SOCKS5 handshake (send+recv of init/auth/connect). On a dead/slow .onion
// the recv() in Socks5() would otherwise block until Tor's own ~120s
// SocksTimeout fires, holding an outbound connection slot.
if (mapArgs.count("-torconnecttimeout"))
{
int nTorTimeout = GetArg("-torconnecttimeout", 60000);
if (IsValidSocksNegotiationTimeout(nTorTimeout))
nSocksNegotiationTimeout = nTorTimeout;
else
InitWarning("Ignoring -torconnecttimeout=" + mapArgs["-torconnecttimeout"] +
": out of range (5000..180000 ms), using default 60000");
}
if (mapArgs.count("-paytxfee"))
{
if (!ParseMoney(mapArgs["-paytxfee"], nTransactionFee))
+170 -39
View File
@@ -564,6 +564,14 @@ CNode* ConnectNode(CAddress addrConnect, const char *pszDest)
void CNode::CloseSocketDisconnect()
{
fDisconnect = true;
// Option C: track this disconnect for the reliability score. We increment
// BEFORE closing the socket so a flurry of disconnects from one peer is
// visible to the next sync manager tick (which iterates cs_vNodes).
++nDisconnectCount;
nLastDisconnectTime = GetTime();
// Penalize the score by 25 per disconnect. Flapping peers (5+ in 5min) get
// an extra 50 penalty applied in the score recompute.
nReliabilityScore = std::max(0, nReliabilityScore - 25);
if (hSocket != INVALID_SOCKET)
{
printf("disconnecting node %s\n", addrName.c_str());
@@ -581,6 +589,40 @@ void CNode::Cleanup()
{
}
int CNode::RecomputeReliabilityScore()
{
// Option C: compute reliability score from current counters.
//
// Base: 100
// -10 per connect failure (host unreachable on attempt)
// -25 per disconnect (also applied immediately in CloseSocketDisconnect,
// but we re-apply here so a fresh CNode that started with a low score
// can recover)
// +5 per block delivered, capped at +200
// -50 if the peer has flapped (5+ disconnects in the last 5 minutes)
//
// Floor: 0 (peer effectively banned from sync)
// Ceiling: 500
int score = 100;
score -= 10 * nConnectFailures;
score -= 25 * nDisconnectCount;
int deliveryBonus = std::min(200, 5 * nBlocksDelivered);
score += deliveryBonus;
if (nDisconnectCount >= 5) {
// Flapping detection: 5+ disconnects in the peer's lifetime.
// We can't easily check "last 5 min" without history, so we use
// total count as a proxy. A peer that connects/disconnects a lot
// is unreliable regardless of timing.
score -= 50;
}
if (score < 0) score = 0;
if (score > 500) score = 500;
nReliabilityScore = score;
return score;
}
void CNode::PushVersion()
{
@@ -1700,67 +1742,109 @@ bool ThreadHTTPSeedFetch2(void* parg)
return false;
}
std::string headers = response.substr(0, headerEnd);
std::string body = response.substr(headerEnd + 4);
// Parse one address per line: "address:port" or just "address"
int found = 0;
std::istringstream lines(body);
std::string line;
while (std::getline(lines, line))
// Some servers (e.g. Caddy / Let's Encrypt fronting the seed list) reply
// with Transfer-Encoding: chunked even on HTTP/1.1 + Connection: close. The
// body then carries hex chunk-size lines interleaved with the data; parsing
// it raw fuses a chunk marker onto an address and we lose most of the list
// (the classic "only 1 address" symptom). De-chunk first when present.
//
// v5.9.22 hardening: the parser is now strict and reports a distinct
// failure code for each kind of malformed framing. See DechunkResult in
// netbase.h and the unit tests in src/test/http_seed_tests.cpp.
{
if (fShutdown)
return false;
std::string h = headers;
for (char& c : h) c = (char)tolower((unsigned char)c);
if (h.find("transfer-encoding:") != std::string::npos &&
h.find("chunked") != std::string::npos)
{
std::string decoded;
int rc = DechunkTransferEncoding(body, decoded);
if (rc != DECHUNK_OK) {
const char* reason = "unknown";
switch (rc) {
case DECHUNK_EMPTY: reason = "empty body"; break;
case DECHUNK_NO_CHUNK_TERMINATOR: reason = "missing chunk terminator (CRLF)"; break;
case DECHUNK_INVALID_HEX: reason = "malformed chunk-size (not valid hex)"; break;
case DECHUNK_OVERSIZE_CHUNK: reason = "chunk size exceeds remaining input (truncated)"; break;
case DECHUNK_MISSING_DATA_CRLF: reason = "missing CRLF after chunk data"; break;
default: reason = "unknown"; break;
}
printf("HTTPS seed fetch: malformed chunked transfer encoding (%s) from %s\n",
reason, seedHost.c_str());
return false;
}
body.swap(decoded);
}
}
// Trim whitespace and carriage returns
while (!line.empty() && (line.back() == '\r' || line.back() == ' ' || line.back() == '\t'))
line.pop_back();
while (!line.empty() && (line.front() == ' ' || line.front() == '\t'))
line.erase(line.begin());
if (fDebug)
printf("HTTPS seed fetch: %d body bytes to parse\n", (int)body.size());
if (line.empty() || line[0] == '#')
continue;
// Tolerant parse: accept one-per-line OR several addresses on one line
// (whitespace / comma / semicolon separated), and ignore inline '#' comments.
// v5.9.22: the splitting logic is now a pure function in netbase.cpp so
// we can unit-test every line format. The CNetAddr/CService/addrman
// validation stays here because it touches globals.
int found = 0;
int skipped = 0;
auto addSeed = [&](std::string addrStr) -> void {
while (!addrStr.empty() && (addrStr.back()=='\r' || addrStr.back()==' ' || addrStr.back()=='\t'))
addrStr.pop_back();
while (!addrStr.empty() && (addrStr.front()==' ' || addrStr.front()=='\t'))
addrStr.erase(addrStr.begin());
if (addrStr.empty())
return;
// Parse address:port
std::string addrStr = line;
int port = GetDefaultPort();
// For .onion addresses, the last colon before port is after ".onion"
size_t onionPos = addrStr.find(".onion:");
if (onionPos != std::string::npos) {
port = atoi(addrStr.substr(onionPos + 7).c_str());
addrStr = addrStr.substr(0, onionPos + 6); // keep ".onion"
} else if (addrStr.find(".onion") == std::string::npos) {
// Tor-native: skip non-.onion addresses
continue;
return; // Tor-native: skip non-.onion addresses
}
if (port <= 0 || port > 65535)
port = GetDefaultPort();
CNetAddr parsed;
bool resolved = parsed.SetSpecial(addrStr);
if (!resolved) {
std::vector<CNetAddr> vIP;
if (LookupHost(addrStr.c_str(), vIP, 1, false) && !vIP.empty()) {
parsed = vIP[0];
resolved = true;
}
}
if (resolved) {
CAddress addr(CService(parsed, port));
CService service(addrStr, port);
if (service.IsValid()) {
CAddress addr(service);
addr.nTime = GetTime() - 3*24*60*60; // 3 days ago
addrman.Add(addr, CNetAddr("https-seed", true));
// Queue the first 8 seeds for immediate direct connection
if (found < 8) {
std::string oneShotAddr = addrStr + ":" + std::to_string(port);
AddOneShot(oneShotAddr);
}
addrman.Add(addr, service);
printf("HTTPS seed: added %s:%d\n", addrStr.c_str(), port);
found++;
} else {
skipped++;
}
};
// Use the pure helper to split the body. If it returns nothing, that
// means the body was entirely comments / blank lines / whitespace —
// distinct failure mode worth logging separately from "no valid
// addresses after parsing".
std::vector<std::string> tokens = ParseSeedListBody(body);
if (tokens.empty()) {
printf("HTTPS seed fetch: parsed response contained zero valid addresses from %s\n", seedHost.c_str());
return false;
}
for (const std::string& tok : tokens)
{
if (fShutdown)
return false;
addSeed(tok);
}
printf("%d addresses found from HTTPS seed list (%s)\n", found, seedHost.c_str());
return found > 0;
if (found == 0) {
printf("HTTPS seed fetch: parsed response contained zero valid addresses from %s\n", seedHost.c_str());
return false;
}
return true;
} catch (std::exception& e) {
printf("HTTPS seed fetch failed: %s\n", e.what());
@@ -1894,10 +1978,57 @@ void ThreadOpenConnections2(void* parg)
// Initiate network connections
int64_t nStart = GetTime();
int64_t nLastDiscoveryRound = 0; // signed peer discovery: re-trigger getaddr+getseederlist+getwalletaddr
const int64_t DISCOVERY_COOLDOWN = 300; // 5min between rounds (peer count < threshold)
const int DISCOVERY_THRESHOLD = 4; // if we have fewer than this many connected peers, re-trigger
while (true)
{
ProcessOneShot();
// Signed peer discovery: when our connected-peer count drops, re-trigger
// the full signing + discovery round on every peer. Triangles already has
// getaddr / getseederlist / getwalletaddr in onion_v3.cpp — this just
// re-fires them periodically instead of only at startup.
int nConnectedOnion = 0;
int nSignedPeers = 0;
int64_t nNow = GetTime();
{
LOCK(cs_vNodes);
for (CNode* pnode : vNodes) {
if (!pnode->fInbound && pnode->fSuccessfullyConnected) {
std::string ip = pnode->addr.ToStringIP();
if (ip.find(".onion") != std::string::npos) {
nConnectedOnion++;
if (pnode->nSignedPeerBonus > 0) nSignedPeers++;
}
}
}
}
if (nConnectedOnion < DISCOVERY_THRESHOLD &&
nNow - nLastDiscoveryRound > DISCOVERY_COOLDOWN)
{
nLastDiscoveryRound = nNow;
printf("SYNC-SIGN: low peer count (%d < %d), re-firing discovery round on all peers\n",
nConnectedOnion, DISCOVERY_THRESHOLD);
LOCK(cs_vNodes);
for (CNode* pnode : vNodes) {
if (!pnode->fInbound && pnode->fSuccessfullyConnected) {
std::string ip = pnode->addr.ToStringIP();
if (ip.find(".onion") != std::string::npos &&
nNow - pnode->nLastGetaddrTrigger > DISCOVERY_COOLDOWN)
{
pnode->nLastGetaddrTrigger = nNow;
pnode->PushMessage("getaddr");
pnode->PushMessage("getseederlist");
// getwalletaddr is only sent on version handshake (main.cpp:3941);
// we don't re-fire it here because it generates a new receiving
// key on the peer each call, which is wasteful. Signed peers
// are cached for 24h (onion_v3.cpp:2308) so they'll be reused.
}
}
}
}
vnThreadsRunning[THREAD_OPENCONNECTIONS]--;
MilliSleep(500);
vnThreadsRunning[THREAD_OPENCONNECTIONS]++;
+17
View File
@@ -261,6 +261,15 @@ public:
int nBestKnownHeight; // highest block height known to this peer (updated from inv/block msgs)
int nIncompatibleGetblocks; // count of getblocks with no common blocks (fork detection)
// Option C: peer reliability scoring. Higher = more reliable.
// Starts at 100 (neutral), grows with successful block delivery, shrinks with
// disconnects and unreachable-on-connect. Used by sync manager to prefer
// reliable peers for header/block requests and to demote flaky ones.
int nReliabilityScore = 100;
int nDisconnectCount = 0; // disconnects since startup
int nConnectFailures = 0; // host-unreachable on connect attempts
int64_t nLastDisconnectTime = 0; // for flapping detection (many disconnects in short window)
// BIP 31 ping/pong latency tracking
uint64_t nPingNonceSent; // nonce of last ping sent (0 = no outstanding ping)
int64_t nPingUsecStart; // microsecond timestamp when last ping was sent
@@ -271,6 +280,8 @@ public:
std::vector<CAddress> vAddrToSend;
mruset<CAddress> setAddrKnown;
bool fGetAddr;
int64_t nLastGetaddrTrigger; // last time we sent this peer a discovery round (getaddr+getseederlist+getwalletaddr)
int nSignedPeerBonus; // +N reputation when peer completed walletaddr handshake (signed identity)
std::set<uint256> setKnown;
uint256 hashCheckpointKnown; // triangles: known sent sync-checkpoint
@@ -325,6 +336,8 @@ public:
nPingUsecTime = 0;
nPingRetryCount = 0;
fGetAddr = false;
nLastGetaddrTrigger = 0;
nSignedPeerBonus = 0;
nMisbehavior = 0;
hashCheckpointKnown = 0;
setInventoryKnown.max_size(SendBufferSize() / 1000);
@@ -549,6 +562,10 @@ public:
void CancelSubscribe(unsigned int nChannel);
void CloseSocketDisconnect();
void Cleanup();
// Option C: recompute reliability score from current counters.
// Call this periodically (e.g. in sync manager tick) to apply the
// flapping penalty (5+ disconnects in 5min = extra 50 penalty).
int RecomputeReliabilityScore();
// Denial-of-service detection/prevention
+179
View File
@@ -12,6 +12,12 @@
#include <sys/fcntl.h>
#endif
#include <cstdlib>
#include <cctype>
#include <cerrno>
#include <limits>
#include <sstream>
#include "strlcpy.h"
using namespace std;
@@ -21,6 +27,13 @@ static proxyType proxyInfo[NET_MAX];
static proxyType nameproxyInfo;
static CCriticalSection cs_proxyInfos;
int nConnectTimeout = 5000;
// Bound for the SOCKS5 negotiation over Tor (ms). The recv() calls in Socks5()
// wait for Tor to build a circuit and fetch the v3 hidden-service descriptor for
// the target .onion; with no timeout a dead/slow onion blocks the connecting
// thread (holding an outbound slot) until Tor's own ~120s SocksTimeout fires.
// Configurable via -torconnecttimeout. Default 60s: long enough for a healthy
// onion to answer, short enough that bad peers don't starve a from-zero node.
int nSocksNegotiationTimeout = 60000;
bool fNameLookup = false;
static const unsigned char pchIPv4[12] = { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff };
@@ -223,6 +236,24 @@ bool static Socks5(string strDest, int port, SOCKET& hSocket)
closesocket(hSocket);
return error("Hostname too long");
}
// Bound the blocking SOCKS5 handshake so a slow/dead .onion can't stall this
// thread (and hold an outbound connection slot) waiting on Tor. A timeout makes
// the recv() below return < expected, which the existing checks treat as a
// clean failure so the connector moves on to the next peer.
{
#ifdef WIN32
DWORD tv = (DWORD)nSocksNegotiationTimeout;
setsockopt(hSocket, SOL_SOCKET, SO_RCVTIMEO, (const char*)&tv, sizeof(tv));
setsockopt(hSocket, SOL_SOCKET, SO_SNDTIMEO, (const char*)&tv, sizeof(tv));
#else
struct timeval tv;
tv.tv_sec = nSocksNegotiationTimeout / 1000;
tv.tv_usec = (nSocksNegotiationTimeout % 1000) * 1000;
setsockopt(hSocket, SOL_SOCKET, SO_RCVTIMEO, (const void*)&tv, sizeof(tv));
setsockopt(hSocket, SOL_SOCKET, SO_SNDTIMEO, (const void*)&tv, sizeof(tv));
#endif
}
char pszSocks5Init[] = "\5\1\0";
if (fDebug)
{
@@ -1287,3 +1318,151 @@ void CService::SetPort(unsigned short portIn)
{
port = portIn;
}
// ═══════════════════════════════════════════════════════════════════════════════
// v5.9.22 hardening: pure helper functions for the HTTPS seed-list path.
// See netbase.h for the contract. These are intentionally free of SSL/Tor
// dependencies so they can be unit-tested in isolation.
// ═══════════════════════════════════════════════════════════════════════════════
bool IsValidSocksNegotiationTimeout(int nMs)
{
// Range bounds match the documented -torconnecttimeout contract. 5000ms
// is the lower edge that still tolerates a slow SOCKS handshake over a
// congested link; 180000ms (3 min) is the upper edge to prevent a stuck
// thread from holding an outbound connection slot indefinitely. These
// constants are duplicated in src/init.cpp's HelpMessage text and the
// test suite — keep all three in sync.
return nMs >= 5000 && nMs <= 180000;
}
int DechunkTransferEncoding(const std::string& body, std::string& decoded)
{
decoded.clear();
if (body.empty())
return DECHUNK_EMPTY;
// HTTP chunked framing requires every chunk-size line to be terminated
// by CRLF. We walk the body one chunk at a time and validate each piece.
// The previous implementation silently dropped malformed chunks and
// treated them as the last-chunk marker, which lost the entire seed list
// for any non-conforming server. This version returns an explicit error
// code for each failure mode.
size_t pos = 0;
const size_t n = body.size();
bool sawLastChunk = false;
while (pos < n) {
// Find end of chunk-size line. Required: CRLF.
size_t eol = body.find("\r\n", pos);
if (eol == std::string::npos)
return DECHUNK_NO_CHUNK_TERMINATOR;
std::string sizeLine = body.substr(pos, eol - pos);
pos = eol + 2; // consume CRLF
// Strip chunk extensions per RFC 7230 §4.1.1: ";name[=value]" after
// the hex size. Extensions are part of the framing protocol, not
// data, so we drop them here.
size_t semi = sizeLine.find(';');
std::string hexSize = (semi == std::string::npos) ? sizeLine : sizeLine.substr(0, semi);
// Strict hex validation: every character must be [0-9A-Fa-f]. Empty
// size lines (e.g. a stray CRLF) are rejected as malformed, not
// silently treated as 0. strtoul alone would also accept leading
// whitespace, '+', and '-' which we don't want.
if (hexSize.empty())
return DECHUNK_INVALID_HEX;
for (size_t i = 0; i < hexSize.size(); ++i) {
if (!isxdigit(static_cast<unsigned char>(hexSize[i])))
return DECHUNK_INVALID_HEX;
}
// strtoul returns ULONG_MAX on overflow. We also need to guard
// against chunks larger than the remaining input, which the old
// code clamped silently. Use strtoull so we can detect overflow
// without truncation surprises on 32-bit builds.
errno = 0;
char* endp = nullptr;
unsigned long long chunkSize = strtoull(hexSize.c_str(), &endp, 16);
if (errno == ERANGE || chunkSize > std::numeric_limits<size_t>::max())
return DECHUNK_INVALID_HEX;
if (endp == hexSize.c_str())
return DECHUNK_INVALID_HEX;
if (chunkSize == 0) {
// Last-chunk: payload is empty, trailer part (which we ignore)
// follows and is terminated by a final CRLF on its own line.
sawLastChunk = true;
break;
}
// Bounds check before reading the chunk data. Catching this
// explicitly (rather than clamping) is what lets callers
// distinguish "truncated network read" from "server sent us junk".
if (chunkSize > n - pos)
return DECHUNK_OVERSIZE_CHUNK;
decoded.append(body, pos, static_cast<size_t>(chunkSize));
pos += static_cast<size_t>(chunkSize);
// Per RFC 7230 each chunk's data must be followed by a CRLF. We
// tolerate the final chunk missing its trailing CRLF (some clients
// do this when the connection is being closed anyway), but for any
// non-final chunk a missing CRLF is a hard framing error.
if (pos + 1 < n && body[pos] == '\r' && body[pos + 1] == '\n') {
pos += 2;
} else if (pos >= n) {
// End of input immediately after chunk data — no CRLF, but
// nothing left to misframe. Reject to be strict.
return DECHUNK_MISSING_DATA_CRLF;
} else {
return DECHUNK_MISSING_DATA_CRLF;
}
}
if (!sawLastChunk) {
// Body ended without a last-chunk marker. Treat as malformed
// rather than accepting a truncated body.
return DECHUNK_NO_CHUNK_TERMINATOR;
}
return DECHUNK_OK;
}
std::vector<std::string> ParseSeedListBody(const std::string& body)
{
std::vector<std::string> out;
std::istringstream lines(body);
std::string line;
while (std::getline(lines, line)) {
// Strip inline '#' comments. Per common seed-list convention, the
// first '#' to end-of-line is comment.
size_t hashPos = line.find('#');
if (hashPos != std::string::npos)
line = line.substr(0, hashPos);
// Split on whitespace, comma, or semicolon so multiple addresses
// on one line are all captured. CR/LF are already consumed by
// std::getline but a trailing CR (LF-only line endings) is trimmed
// implicitly by skipping it as a separator below.
size_t start = 0;
while (start <= line.size()) {
size_t sep = line.find_first_of(" \t,;", start);
std::string tok = (sep == std::string::npos)
? line.substr(start)
: line.substr(start, sep - start);
// Trim CR and any leftover whitespace from the token. The
// 'sep' loop above eats spaces/tabs but a bare CR survives.
while (!tok.empty() && (tok.back() == '\r' || tok.back() == ' ' || tok.back() == '\t'))
tok.pop_back();
while (!tok.empty() && (tok.front() == ' ' || tok.front() == '\t'))
tok.erase(tok.begin());
if (!tok.empty())
out.push_back(tok);
if (sep == std::string::npos) break;
start = sep + 1;
}
}
return out;
}
+70
View File
@@ -29,8 +29,78 @@ enum Network
};
extern int nConnectTimeout;
extern int nSocksNegotiationTimeout;
extern bool fNameLookup;
// ═══════════════════════════════════════════════════════════════════════════════
// v5.9.22 hardening: pure helper functions for the HTTPS seed-list path.
// Extracted from net.cpp ThreadHTTPSeedFetch2 so they can be unit-tested
// without the SSL/Tor network stack. All functions are side-effect free and
// operate on std::string/std::vector<std::string> only.
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Result of dechunking an HTTP/1.1 chunked body. The daemon used to silently
* treat malformed framing as a zero-length chunk, which dropped the entire
* seed list. This enum lets the caller distinguish each failure mode and
* surface it in logs.
*/
enum DechunkResult {
DECHUNK_OK = 0, // success
DECHUNK_EMPTY, // body is empty
DECHUNK_NO_CHUNK_TERMINATOR, // missing CRLF after a chunk-size line
DECHUNK_INVALID_HEX, // chunk-size line is not valid hex
DECHUNK_OVERSIZE_CHUNK, // declared chunk size exceeds remaining input
DECHUNK_MISSING_DATA_CRLF, // CRLF missing after a chunk's data
};
/**
* Decode an HTTP/1.1 Transfer-Encoding: chunked body.
*
* chunked-body = *chunk last-chunk trailer-part CRLF
* chunk = chunk-size [ chunk-ext ] CRLF chunk-data CRLF
* chunk-size = 1*HEXDIG
* last-chunk = 1*("0") [ chunk-ext ] CRLF
* chunk-ext = *( ";" chunk-ext-name [ "=" chunk-ext-val ] )
*
* @param[in] body the raw body bytes after the header terminator
* @param[out] decoded the dechunked payload on success
* @return status code (DECHUNK_OK or one of the failure modes)
*
* The implementation is intentionally strict: a malformed hex digit, a
* missing CRLF, or a chunk whose declared size is larger than the remaining
* input all return an explicit error code rather than silently clamping.
* Chunk extensions ("a;foo=bar") are preserved (stripped from the size
* line) so legitimate servers that attach metadata to chunks are still
* accepted.
*/
int DechunkTransferEncoding(const std::string& body, std::string& decoded);
/**
* Parse a tolerant HTTPS seed-list body into individual host entries.
*
* Accepted per line:
* - one or more addresses separated by whitespace, commas, or semicolons
* - inline "#" comments (everything after '#' is dropped)
* - blank lines
* - CRLF or LF line endings
*
* Each returned entry is the address string (e.g. "abcd...onion:24112" or
* "abcd...onion"). Empty/whitespace-only entries are omitted. The result is
* a list of candidate strings suitable for CNetAddr/CService validation
* downstream.
*/
std::vector<std::string> ParseSeedListBody(const std::string& body);
/**
* Validate the -torconnecttimeout / nSocksNegotiationTimeout value.
*
* Accepts 5000..180000 ms inclusive. Returns true for in-range, false for
* out-of-range. This is the central policy so callers and tests stay in
* sync; do not duplicate the literal numbers elsewhere.
*/
bool IsValidSocksNegotiationTimeout(int nMs);
/** IP address (IPv6, or IPv4 using mapped IPv6 range (::FFFF:0:0/96)) */
class CNetAddr
{
+181 -17
View File
@@ -22,15 +22,24 @@ struct CSyncManager::HeaderNode
int64_t nLastRequestTime;
int64_t nFirstRequestTime;
int64_t nInsertTime;
// Phase 1.5: track which peer this header was last requested from. Used to
// compute per-peer inflight for the cap. Not a hard ownership — the block
// can be re-requested from a different peer if this one stalls.
CNode* pnodeLastRequest = nullptr;
};
namespace
{
static const unsigned int MAX_HEADER_SYNC_CACHE = 15000;
static const unsigned int MAX_HEADER_SYNC_CACHE = 100000;
static const size_t HEADER_REDUNDANT_PEER_THRESHOLD = 4;
static const int64_t HEADER_REQUEST_TIMEOUT_MICROS = 60 * 1000000;
static const int64_t HEADER_REDUNDANT_REQUEST_MICROS = 5 * 1000000;
static const int64_t HEADER_SYNC_TTL_MICROS = 15 * 60 * 1000000;
// Backpressure ceiling: how far (in blocks) the header front is allowed to
// run ahead of the connected chain tip before we stop fetching MORE headers
// and let the block planner catch up. Comfortly below MAX_HEADER_SYNC_CACHE
// so the cache never overflows in normal from-zero sync.
static const int HEADER_FRONT_MAX_AHEAD = 32768;
std::map<uint256, CSyncManager::HeaderNode> mapHeaders;
uint256 hashBestHeader = 0;
@@ -124,12 +133,23 @@ void CSyncManager::PruneHeaders()
{
const int64_t nNow = GetTime() * 1000000;
// Never evict headers in the live sync window. Fix 2's backpressure caps the
// header front at nBestHeight + HEADER_FRONT_MAX_AHEAD, so protecting that
// whole span means the entire in-flight header chain is safe: no mid-chain
// hole can form between the connected tip and the front during normal sync.
// Evicting any of these would sever GetDownloadPath() from the connected
// chain and freeze sync. (The hard cap below is the memory safety valve;
// Fix 3's bridge-repair is the backstop for restart/reorg edge cases.)
const int nProtectFloor = nBestHeight + HEADER_FRONT_MAX_AHEAD;
// TTL pass: evict aged headers, but only ABOVE the protected floor.
if (mapHeaders.size() > MAX_HEADER_SYNC_CACHE / 2)
{
unsigned int nEvicted = 0;
for (std::map<uint256, HeaderNode>::iterator it = mapHeaders.begin(); it != mapHeaders.end(); )
{
if (nNow - it->second.nInsertTime >= HEADER_SYNC_TTL_MICROS)
if (it->second.nHeight > nProtectFloor &&
nNow - it->second.nInsertTime >= HEADER_SYNC_TTL_MICROS)
{
it = mapHeaders.erase(it);
++nEvicted;
@@ -139,25 +159,37 @@ void CSyncManager::PruneHeaders()
}
if (nEvicted > 0)
{
printf("IBD-DIAG: TTL-evicted %u stale sync headers, %u remain\n",
nEvicted, (unsigned int)mapHeaders.size());
printf("IBD-DIAG: TTL-evicted %u stale sync headers above floor %d, %u remain\n",
nEvicted, nProtectFloor, (unsigned int)mapHeaders.size());
RecomputeBestHeader();
}
}
// Hard cap (last-resort safety valve): evict the HIGHEST-height headers
// first — the ones furthest ahead of the tip — never the bridge zone.
// Lowering the sync target temporarily is fine; we re-extend it once blocks
// catch up. Losing a bridge header is not fine: it stalls forever.
if (mapHeaders.size() > MAX_HEADER_SYNC_CACHE)
{
printf("IBD-DIAG: sync header cache exceeded %u entries, evicting oldest\n", MAX_HEADER_SYNC_CACHE);
while (mapHeaders.size() > MAX_HEADER_SYNC_CACHE * 3 / 4)
{
std::map<uint256, HeaderNode>::iterator oldest = mapHeaders.begin();
for (std::map<uint256, HeaderNode>::iterator it = mapHeaders.begin(); it != mapHeaders.end(); ++it)
{
if (it->second.nInsertTime < oldest->second.nInsertTime)
oldest = it;
}
mapHeaders.erase(oldest);
}
printf("IBD-DIAG: sync header cache exceeded %u entries, evicting from the front (floor=%d)\n",
MAX_HEADER_SYNC_CACHE, nProtectFloor);
std::vector<std::map<uint256, HeaderNode>::iterator> vEvictable;
for (std::map<uint256, HeaderNode>::iterator it = mapHeaders.begin(); it != mapHeaders.end(); ++it)
if (it->second.nHeight > nProtectFloor)
vEvictable.push_back(it);
std::sort(vEvictable.begin(), vEvictable.end(),
[](const std::map<uint256, HeaderNode>::iterator& a,
const std::map<uint256, HeaderNode>::iterator& b) {
return a->second.nHeight > b->second.nHeight;
});
const size_t nTarget = (size_t)MAX_HEADER_SYNC_CACHE * 3 / 4;
size_t i = 0;
while (mapHeaders.size() > nTarget && i < vEvictable.size())
mapHeaders.erase(vEvictable[i++]);
RecomputeBestHeader();
}
}
@@ -191,7 +223,11 @@ bool CSyncManager::AddHeaderNode(const CBlock& header, const uint256& hashHeader
}
const int nHeight = nPrevHeight + 1;
if (nHeight <= CUTOFF_POW_BLOCK && !CheckProofOfWork(hashHeader, header.nBits))
// Only check PoW on actual PoW headers (nNonce != 0).
// Triangles is a hybrid PoW/PoS coin — PoS blocks (nonce=0) can appear
// even within the 0-9000 PoW range. Checking PoW on a PoS header
// rejects valid blocks and severs the header chain during IBD.
if (nHeight <= CUTOFF_POW_BLOCK && header.nNonce != 0 && !CheckProofOfWork(hashHeader, header.nBits))
{
printf("IBD-DIAG: header PoW FAILED at height %d hash=%s nBits=%08x prevHash=%s\n",
nHeight, hashHeader.ToString().substr(0,20).c_str(), header.nBits,
@@ -235,6 +271,20 @@ std::vector<uint256> CSyncManager::GetDownloadPath(uint256 hashTip) const
return vPath;
}
// A download path is "anchored" when its lowest header's parent is a block we
// already have in the active chain (mapBlockIndex). If it isn't, a bridging
// header was lost and requesting these blocks would only create orphans —
// Tick() rebuilds the bridge with a getheaders anchored at pindexBest.
bool CSyncManager::PathReachesChain(const std::vector<uint256>& vPath) const
{
if (vPath.empty())
return true; // nothing queued == nothing to bridge
std::map<uint256, HeaderNode>::const_iterator mi = mapHeaders.find(vPath.front());
if (mi == mapHeaders.end())
return false;
return mapBlockIndex.count(mi->second.header.hashPrevBlock) != 0;
}
unsigned int CSyncManager::CountInFlight() const
{
const int64_t nNow = GetTime() * 1000000;
@@ -291,6 +341,13 @@ void CSyncManager::ContinueHeaders(CNode* pfrom, const uint256& hashTip)
if (!pfrom || hashTip == 0)
return;
// Backpressure: don't extend the header front when it is already far ahead
// of the connected block tip. Otherwise headers race past the block planner
// and the bridge headers age out / get evicted before their blocks arrive.
if (hashBestHeader != 0 &&
GetPlannerHeight() - nBestHeight > HEADER_FRONT_MAX_AHEAD)
return;
std::vector<uint256> vHave;
uint256 hashWalk = hashTip;
int nStep = 1;
@@ -326,6 +383,12 @@ bool CSyncManager::RequestRefill(CNode* pfrom, uint256 hashTip, int64_t nMinInte
nNowSec - pfrom->nLastIbdHeaderRequest < nMinIntervalSeconds)
return false;
// Backpressure: stop pulling new headers once the front is far enough ahead
// of the connected tip; let block download drain first (see ContinueHeaders).
if (hashBestHeader != 0 &&
GetPlannerHeight() - nBestHeight > HEADER_FRONT_MAX_AHEAD)
return false;
uint256 hashLocatorTip = hashTip;
if (hashLocatorTip == 0 ||
(!mapBlockIndex.count(hashLocatorTip) && !mapHeaders.count(hashLocatorTip)))
@@ -386,6 +449,15 @@ unsigned int CSyncManager::QueueBlocksParallel(unsigned int nWindow)
if (vPath.empty())
return 0;
// If the path doesn't connect back to the active chain, requesting these
// blocks just fills the orphan pool. Bail and let Tick() repair the bridge.
if (!PathReachesChain(vPath))
{
printf("IBD-DIAG: download path not anchored to chain (front=%s) — deferring to bridge repair\n",
vPath.front().ToString().substr(0,20).c_str());
return 0;
}
std::vector<CNode*> vEligiblePeers;
{
LOCK(cs_vNodes);
@@ -404,8 +476,26 @@ unsigned int CSyncManager::QueueBlocksParallel(unsigned int nWindow)
unsigned int nQueued = 0;
unsigned int nPeerIndex = 0;
// Option C: sort eligible peers by reliability score, not just blocks delivered.
// A peer that's delivered 100 blocks but disconnected 20 times is less reliable
// than a peer that's delivered 50 blocks with 0 disconnects. The score captures
// both. We also drop peers with score <= 0 (effectively banned from sync).
for (CNode* pnode : vEligiblePeers) {
pnode->RecomputeReliabilityScore();
}
vEligiblePeers.erase(
std::remove_if(vEligiblePeers.begin(), vEligiblePeers.end(),
[](const CNode* p) { return p->nReliabilityScore <= 0; }),
vEligiblePeers.end());
std::sort(vEligiblePeers.begin(), vEligiblePeers.end(),
[](const CNode* a, const CNode* b) {
// Sort by reliability score (primary), then signed-peer bonus (signed > unsigned),
// then blocks delivered (tiebreaker).
if (a->nReliabilityScore != b->nReliabilityScore)
return a->nReliabilityScore > b->nReliabilityScore;
if (a->nSignedPeerBonus != b->nSignedPeerBonus)
return a->nSignedPeerBonus > b->nSignedPeerBonus;
return a->nBlocksDelivered > b->nBlocksDelivered;
});
@@ -437,6 +527,22 @@ unsigned int CSyncManager::QueueBlocksParallel(unsigned int nWindow)
}
}
// Phase 1.5: per-peer inflight counting via HeaderNode.pnodeLastRequest.
// Skip a peer if they're at their share of the global window. This caps the
// damage a single .onion peer can do if they're feeding low-quality blocks.
const unsigned int nPerPeerCap = HEADER_DOWNLOAD_WINDOW / std::max(1u, (unsigned int)vEligiblePeers.size()) + 1;
std::map<const CNode*, unsigned int> mapPeerInflight;
{
const int64_t nNowInflight = GetTime() * 1000000;
for (const auto& kv : mapHeaders) {
if (kv.second.fRequested &&
(nNowInflight - kv.second.nLastRequestTime) < HEADER_REQUEST_TIMEOUT_MICROS &&
kv.second.pnodeLastRequest != nullptr) {
++mapPeerInflight[kv.second.pnodeLastRequest];
}
}
}
for (std::vector<uint256>::const_iterator it = vPath.begin(); it != vPath.end(); ++it)
{
if (nInFlight + nQueued >= nWindow)
@@ -457,9 +563,32 @@ unsigned int CSyncManager::QueueBlocksParallel(unsigned int nWindow)
if (!fNeedsRequest)
continue;
CNode* pnode = vWeightedPeers[nPeerIndex % vWeightedPeers.size()];
// Phase 1.5: skip peers that are at their share of the global window. We
// try the weighted peer first, and if they're capped, fall back to any
// other eligible peer that's under the cap. This ensures one peer can't
// claim the whole window even if they're the highest-weighted.
CNode* pnode = nullptr;
for (size_t tryIdx = 0; tryIdx < vWeightedPeers.size(); ++tryIdx) {
CNode* candidate = vWeightedPeers[(nPeerIndex + tryIdx) % vWeightedPeers.size()];
unsigned int candidateInflight = mapPeerInflight.count(candidate) ? mapPeerInflight[candidate] : 0;
if (candidateInflight < nPerPeerCap) {
pnode = candidate;
nPeerIndex = (nPeerIndex + tryIdx) % vWeightedPeers.size();
break;
}
}
if (!pnode) {
// All peers at cap — skip this block for now, it'll be retried later
continue;
}
pnode->AskFor(CInv(MSG_BLOCK, *it));
// Phase 1.5: record which peer this block was requested from for the
// per-peer inflight count
mi->second.pnodeLastRequest = pnode;
mapPeerInflight[pnode] = (mapPeerInflight.count(pnode) ? mapPeerInflight[pnode] : 0) + 1;
if (IsInitialBlockDownload() &&
vWeightedPeers.size() >= 2 &&
vWeightedPeers.size() < HEADER_REDUNDANT_PEER_THRESHOLD &&
@@ -581,6 +710,10 @@ void CSyncManager::TrackBlockDelivery(CNode* pfrom, const uint256& hashBlock)
return;
pfrom->nBlocksDelivered++;
// Option C: reward the peer for delivering a block. Capped at +200 by
// RecomputeReliabilityScore. Also recompute to apply any flapping penalty
// that may have accumulated since the last recompute.
pfrom->nReliabilityScore = std::min(500, pfrom->nReliabilityScore + 5);
if (nBestHeight > pfrom->nBestKnownHeight)
pfrom->nBestKnownHeight = nBestHeight;
@@ -626,6 +759,22 @@ void CSyncManager::Tick(CNode* pto, int nHighestInvWalk, const uint256& hashHigh
nLastHeaderPlannerControl = nNowSec;
}
// Pipeline refill: when the in-flight block window has drained (inflight==0)
// and the planner still has headers ahead of the connected tip, kick a fresh
// getheaders round on every eligible peer so the next batch of blocks is
// requested BEFORE the current download finishes. Closes the "Tor pipe
// empty" gaps that stall throughput between burst windows.
if (nInFlight < (unsigned int)(HEADER_DOWNLOAD_WINDOW / 16) &&
nPlannerDepth > 0)
{
const unsigned int nPipeline = RequestRefillAllPeers(
hashBestHeader, HEADER_SYNC_REFILL_MIN_INTERVAL_SECONDS,
"pipeline-prefetch");
if (nPipeline > 0)
printf("IBD-DIAG: pipeline-prefetch refill %u (plannerDepth=%u inflight=%u)\n",
nPipeline, nPlannerDepth, nInFlight);
}
if (nNowSec - nLastHeaderWatchdog >= HEADER_SYNC_CONTROL_INTERVAL_SECONDS &&
nNowSec - nLastNewHeaderTime >= HEADER_SYNC_WATCHDOG_SECONDS)
{
@@ -645,6 +794,21 @@ void CSyncManager::Tick(CNode* pto, int nHighestInvWalk, const uint256& hashHigh
if (nNowSec - pto->nLastIbdHeaderRequest >= nMinInterval)
RequestRefill(pto, hashBestHeader, nMinInterval, "heartbeat");
// Bridge repair: we have a best header, but the download path can't reach
// the connected chain — a linking header was lost (TTL/eviction/hole). Ask
// this peer for headers with a locator anchored at the REAL chain tip so it
// resends the headers directly above pindexBest and re-links the path.
if (hashBestHeader != 0 && pindexBest &&
!PathReachesChain(GetDownloadPath(hashBestHeader)) &&
nNowSec - pto->nLastIbdHeaderRequest >= HEADER_SYNC_REFILL_MIN_INTERVAL_SECONDS)
{
pto->pindexLastGetHeadersBegin = NULL;
pto->PushGetHeaders(pindexBest, uint256(0));
pto->nLastIbdHeaderRequest = nNowSec;
printf("IBD-DIAG: bridge-repair getheaders from connected tip height=%d peer=%s\n",
pindexBest->nHeight, pto->addr.ToString().c_str());
}
if (nNowSec - nLastBlockPlannerControl >= HEADER_SYNC_CONTROL_INTERVAL_SECONDS &&
hashBestHeader != 0 &&
nPlannerDepth > 0)
+1
View File
@@ -50,6 +50,7 @@ private:
void PruneHeaders();
bool AddHeaderNode(const CBlock& header, const uint256& hashHeader);
std::vector<uint256> GetDownloadPath(uint256 hashTip) const;
bool PathReachesChain(const std::vector<uint256>& vPath) const;
void ContinueHeaders(CNode* pfrom, const uint256& hashTip);
};
+464
View File
@@ -0,0 +1,464 @@
// Copyright (c) 2026 Cryptographic Triangles
// Distributed under the MIT/X11 software license, see the accompanying
// file COPYING or http://www.opensource.org/licenses/mit-license.php.
//
// v5.9.22 hardening tests for the HTTPS seed-list path.
//
// Two pure functions under test (declared in netbase.h):
//
// int DechunkTransferEncoding(const std::string& body, std::string& decoded)
// std::vector<std::string> ParseSeedListBody(const std::string& body)
// bool IsValidSocksNegotiationTimeout(int nMs)
//
// These functions replaced the inline parsers in net.cpp
// ThreadHTTPSeedFetch2. The tests cover every failure mode listed in the
// hardening brief:
// - Normal non-chunked HTTP seed responses (the parser is a no-op)
// - Valid chunked responses with several chunks
// - Chunk extensions such as A;foo=bar
// - Chunked payloads split at awkward boundaries
// - Malformed chunk sizes, missing CRLF, truncated chunks, chunks whose
// declared size exceeds remaining input
// - Seed-list parsing with whitespace, commas, semicolons, comments,
// multiple addresses per line, valid .onion:port entries, and invalid
// entries
// - -torconnecttimeout validation at the exact boundaries and just
// outside them: 4999, 5000, 60000, 180000, and 180001 milliseconds
#include <boost/test/unit_test.hpp>
#include "netbase.h"
#include <string>
#include <vector>
using namespace std;
BOOST_AUTO_TEST_SUITE(http_seed_tests)
// ═══════════════════════════════════════════════════════════════════════════════
// DechunkTransferEncoding — happy path
// ═══════════════════════════════════════════════════════════════════════════════
BOOST_AUTO_TEST_CASE(dechunk_empty_body)
{
string decoded;
BOOST_CHECK_EQUAL(DechunkTransferEncoding(string(""), decoded), DECHUNK_EMPTY);
BOOST_CHECK(decoded.empty());
}
BOOST_AUTO_TEST_CASE(dechunk_single_chunk)
{
// "5\r\nhello\r\n0\r\n\r\n" → "hello"
string body = "5\r\nhello\r\n0\r\n\r\n";
string decoded;
BOOST_CHECK_EQUAL(DechunkTransferEncoding(body, decoded), DECHUNK_OK);
BOOST_CHECK_EQUAL(decoded, "hello");
}
BOOST_AUTO_TEST_CASE(dechunk_multiple_chunks)
{
// Three chunks concatenated: "Hel" + "lo " + "world"
string body = "3\r\nHel\r\n3\r\nlo \r\n5\r\nworld\r\n0\r\n\r\n";
string decoded;
BOOST_CHECK_EQUAL(DechunkTransferEncoding(body, decoded), DECHUNK_OK);
BOOST_CHECK_EQUAL(decoded, "Hello world");
}
BOOST_AUTO_TEST_CASE(dechunk_with_chunk_extension)
{
// "5;foo=bar\r\nhello\r\n0\r\n\r\n" → "hello"
// Extensions after the size are part of the framing protocol and must
// be stripped before parsing the hex size.
string body = "5;foo=bar\r\nhello\r\n0\r\n\r\n";
string decoded;
BOOST_CHECK_EQUAL(DechunkTransferEncoding(body, decoded), DECHUNK_OK);
BOOST_CHECK_EQUAL(decoded, "hello");
}
BOOST_AUTO_TEST_CASE(dechunk_with_multiple_extensions)
{
// "5;a=b;c=d\r\nhello\r\n0\r\n\r\n"
string body = "5;a=b;c=d\r\nhello\r\n0\r\n\r\n";
string decoded;
BOOST_CHECK_EQUAL(DechunkTransferEncoding(body, decoded), DECHUNK_OK);
BOOST_CHECK_EQUAL(decoded, "hello");
}
BOOST_AUTO_TEST_CASE(dechunk_uppercase_hex)
{
// "5\r\nhello\r\n0\r\n\r\n" with A-F uppercase
string body = "A\r\n0123456789\r\n0\r\n\r\n";
string decoded;
BOOST_CHECK_EQUAL(DechunkTransferEncoding(body, decoded), DECHUNK_OK);
BOOST_CHECK_EQUAL(decoded, "0123456789");
}
BOOST_AUTO_TEST_CASE(dechunk_payload_containing_crlf)
{
// Chunk data itself contains CRLF — must not be mistaken for framing.
string body = "B\r\nline1\r\nline2\r\n0\r\n\r\n";
string decoded;
BOOST_CHECK_EQUAL(DechunkTransferEncoding(body, decoded), DECHUNK_OK);
BOOST_CHECK_EQUAL(decoded, "line1\r\nline2");
}
BOOST_AUTO_TEST_CASE(dechunk_split_at_awkward_boundary)
{
// A long chunk whose internal "data" happens to look like a chunk-size
// line. Hex 0x0B = 11 bytes; the data "FAKE\r\nFOO\r" contains CRLF.
string body = "B\r\nFAKE\r\nFOO\r\r\n0\r\n\r\n";
string decoded;
BOOST_CHECK_EQUAL(DechunkTransferEncoding(body, decoded), DECHUNK_OK);
// 11 bytes consumed: "FAKE\r\nFOO\r" (5 + 2 + 3 + 1 = 11)
BOOST_CHECK_EQUAL(decoded, "FAKE\r\nFOO\r");
}
BOOST_AUTO_TEST_CASE(dechunk_last_chunk_with_extension)
{
// "0;end=1\r\n\r\n" — last chunk with extension, no body
string body = "5\r\nhello\r\n0;end=1\r\n\r\n";
string decoded;
BOOST_CHECK_EQUAL(DechunkTransferEncoding(body, decoded), DECHUNK_OK);
BOOST_CHECK_EQUAL(decoded, "hello");
}
// ═══════════════════════════════════════════════════════════════════════════════
// DechunkTransferEncoding — failure modes
// ═══════════════════════════════════════════════════════════════════════════════
BOOST_AUTO_TEST_CASE(dechunk_no_crlf_after_size)
{
// No CRLF after the chunk-size hex — must not be silently accepted.
string body = "5XXhello\r\n0\r\n\r\n";
string decoded;
BOOST_CHECK_EQUAL(DechunkTransferEncoding(body, decoded), DECHUNK_NO_CHUNK_TERMINATOR);
}
BOOST_AUTO_TEST_CASE(dechunk_invalid_hex)
{
// "G" is not a valid hex digit.
string body = "G\r\nhello\r\n0\r\n\r\n";
string decoded;
BOOST_CHECK_EQUAL(DechunkTransferEncoding(body, decoded), DECHUNK_INVALID_HEX);
}
BOOST_AUTO_TEST_CASE(dechunk_empty_size_line)
{
// Stray CRLF at the start — empty size line must be rejected.
string body = "\r\nhello\r\n0\r\n\r\n";
string decoded;
BOOST_CHECK_EQUAL(DechunkTransferEncoding(body, decoded), DECHUNK_INVALID_HEX);
}
BOOST_AUTO_TEST_CASE(dechunk_oversize_chunk)
{
// Declared 100 bytes but only 5 remain. Old code silently clamped;
// strict version must report DECHUNK_OVERSIZE_CHUNK.
string body = "64\r\nhello\r\n0\r\n\r\n";
string decoded;
BOOST_CHECK_EQUAL(DechunkTransferEncoding(body, decoded), DECHUNK_OVERSIZE_CHUNK);
}
BOOST_AUTO_TEST_CASE(dechunk_truncated_last_chunk_marker)
{
// No "0\r\n" terminator — body just ends mid-chunk.
string body = "5\r\nhello\r\n";
string decoded;
BOOST_CHECK_EQUAL(DechunkTransferEncoding(body, decoded), DECHUNK_NO_CHUNK_TERMINATOR);
}
BOOST_AUTO_TEST_CASE(dechunk_missing_data_crlf)
{
// Chunk-data not followed by CRLF. Two chunks: first is 5 bytes "hello"
// then "X" where CRLF should be. The parser must catch the missing CRLF
// before trying to read the next chunk-size.
string body = "5\r\nhelloX3\r\nfoo\r\n0\r\n\r\n";
string decoded;
BOOST_CHECK_EQUAL(DechunkTransferEncoding(body, decoded), DECHUNK_MISSING_DATA_CRLF);
}
BOOST_AUTO_TEST_CASE(dechunk_strtoul_overflow)
{
// A hex value larger than size_t can represent. On a 64-bit system this
// would be 17+ F's. We pick a 32-digit value: clearly overflows on both
// 32 and 64 bit builds.
string body = "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF\r\n0\r\n\r\n";
string decoded;
// Either INVALID_HEX (overflow detected) or OVERSIZE_CHUNK (caught at
// bounds check) is acceptable — both correctly refuse the input.
int rc = DechunkTransferEncoding(body, decoded);
BOOST_CHECK(rc == DECHUNK_INVALID_HEX || rc == DECHUNK_OVERSIZE_CHUNK);
}
BOOST_AUTO_TEST_CASE(dechunk_sign_in_size)
{
// strtoul would silently accept leading '+' or '-'. Our strict
// hex-only validator must reject them.
string body = "+5\r\nhello\r\n0\r\n\r\n";
string decoded;
BOOST_CHECK_EQUAL(DechunkTransferEncoding(body, decoded), DECHUNK_INVALID_HEX);
}
BOOST_AUTO_TEST_CASE(dechunk_whitespace_in_size)
{
// strtoul would silently accept leading whitespace. Our strict
// hex-only validator must reject them.
string body = " 5\r\nhello\r\n0\r\n\r\n";
string decoded;
BOOST_CHECK_EQUAL(DechunkTransferEncoding(body, decoded), DECHUNK_INVALID_HEX);
}
BOOST_AUTO_TEST_CASE(dechunk_no_last_chunk)
{
// Body has chunks but never reaches a size-0 terminator. Must be
// rejected, not silently accepted as the whole body.
string body = "5\r\nhello\r\n"; // missing "0\r\n\r\n"
string decoded;
int rc = DechunkTransferEncoding(body, decoded);
BOOST_CHECK(rc == DECHUNK_NO_CHUNK_TERMINATOR || rc == DECHUNK_MISSING_DATA_CRLF);
}
// ═══════════════════════════════════════════════════════════════════════════════
// ParseSeedListBody
// ═══════════════════════════════════════════════════════════════════════════════
BOOST_AUTO_TEST_CASE(seedlist_empty)
{
vector<string> out = ParseSeedListBody("");
BOOST_CHECK(out.empty());
}
BOOST_AUTO_TEST_CASE(seedlist_single_onion_per_line)
{
// Three v3 onion addresses, one per line.
string body =
"aaaa.onion:24112\n"
"bbbb.onion:24112\n"
"cccc.onion:24112\n";
vector<string> out = ParseSeedListBody(body);
BOOST_CHECK_EQUAL(out.size(), 3u);
BOOST_CHECK_EQUAL(out[0], "aaaa.onion:24112");
BOOST_CHECK_EQUAL(out[1], "bbbb.onion:24112");
BOOST_CHECK_EQUAL(out[2], "cccc.onion:24112");
}
BOOST_AUTO_TEST_CASE(seedlist_crlf_line_endings)
{
// Real-world: HTTP responses typically use CRLF.
string body =
"aaaa.onion:24112\r\n"
"bbbb.onion:24112\r\n";
vector<string> out = ParseSeedListBody(body);
BOOST_CHECK_EQUAL(out.size(), 2u);
BOOST_CHECK_EQUAL(out[0], "aaaa.onion:24112");
BOOST_CHECK_EQUAL(out[1], "bbbb.onion:24112");
}
BOOST_AUTO_TEST_CASE(seedlist_multiple_per_line_space)
{
// Several addresses on one line, space-separated.
string body = "aaaa.onion:24112 bbbb.onion:24112 cccc.onion:24112\n";
vector<string> out = ParseSeedListBody(body);
BOOST_CHECK_EQUAL(out.size(), 3u);
BOOST_CHECK_EQUAL(out[0], "aaaa.onion:24112");
BOOST_CHECK_EQUAL(out[1], "bbbb.onion:24112");
BOOST_CHECK_EQUAL(out[2], "cccc.onion:24112");
}
BOOST_AUTO_TEST_CASE(seedlist_multiple_per_line_comma)
{
// Comma-separated — common in older seed lists.
string body = "aaaa.onion:24112,bbbb.onion:24112,cccc.onion:24112\n";
vector<string> out = ParseSeedListBody(body);
BOOST_CHECK_EQUAL(out.size(), 3u);
}
BOOST_AUTO_TEST_CASE(seedlist_multiple_per_line_semicolon)
{
// Semicolon-separated — sometimes used in INI-style configs.
string body = "aaaa.onion:24112;bbbb.onion:24112;cccc.onion:24112\n";
vector<string> out = ParseSeedListBody(body);
BOOST_CHECK_EQUAL(out.size(), 3u);
}
BOOST_AUTO_TEST_CASE(seedlist_mixed_separators)
{
// Tabs, multiple spaces, commas, semicolons all in one line.
string body = "aaaa.onion:24112,\tbbbb.onion:24112 ;cccc.onion:24112\n";
vector<string> out = ParseSeedListBody(body);
BOOST_CHECK_EQUAL(out.size(), 3u);
}
BOOST_AUTO_TEST_CASE(seedlist_inline_comments)
{
// Anything after '#' to end-of-line is dropped.
string body =
"aaaa.onion:24112 # primary\n"
"# this whole line is a comment\n"
"bbbb.onion:24112 # secondary\n";
vector<string> out = ParseSeedListBody(body);
BOOST_CHECK_EQUAL(out.size(), 2u);
BOOST_CHECK_EQUAL(out[0], "aaaa.onion:24112");
BOOST_CHECK_EQUAL(out[1], "bbbb.onion:24112");
}
BOOST_AUTO_TEST_CASE(seedlist_blank_lines)
{
// Whitespace-only / blank lines are skipped.
string body =
"\n"
" \n"
"aaaa.onion:24112\n"
"\t\n"
"bbbb.onion:24112\n";
vector<string> out = ParseSeedListBody(body);
BOOST_CHECK_EQUAL(out.size(), 2u);
}
BOOST_AUTO_TEST_CASE(seedlist_only_comments)
{
// All-comment body produces empty output (zero valid addresses
// downstream — caller logs the failure mode).
string body =
"# nothing useful here\n"
"# more comments\n";
vector<string> out = ParseSeedListBody(body);
BOOST_CHECK(out.empty());
}
BOOST_AUTO_TEST_CASE(seedlist_portless_onion)
{
// ".onion" without ":port" is allowed at the parser level — the caller
// falls back to GetDefaultPort() before validating as a CService.
string body = "aaaa.onion\nbbbb.onion:24112\n";
vector<string> out = ParseSeedListBody(body);
BOOST_CHECK_EQUAL(out.size(), 2u);
BOOST_CHECK_EQUAL(out[0], "aaaa.onion");
BOOST_CHECK_EQUAL(out[1], "bbbb.onion:24112");
}
BOOST_AUTO_TEST_CASE(seedlist_invalid_entry_preserved_for_caller)
{
// The parser does NOT validate that entries are real .onion addresses
// or valid CService — that's the caller's job. The parser is a pure
// splitter; invalid entries (e.g. "not-a-host") are still returned
// and will fail CService::IsValid() downstream.
string body = "not-a-host\nxxxxxx.onion:24112\n";
vector<string> out = ParseSeedListBody(body);
BOOST_CHECK_EQUAL(out.size(), 2u);
BOOST_CHECK_EQUAL(out[0], "not-a-host");
BOOST_CHECK_EQUAL(out[1], "xxxxxx.onion:24112");
}
BOOST_AUTO_TEST_CASE(seedlist_trailing_whitespace_per_line)
{
// Spaces/tabs at end of each line should not produce an empty token.
string body = "aaaa.onion:24112 \t \n";
vector<string> out = ParseSeedListBody(body);
BOOST_CHECK_EQUAL(out.size(), 1u);
BOOST_CHECK_EQUAL(out[0], "aaaa.onion:24112");
}
BOOST_AUTO_TEST_CASE(seedlist_crlf_lf_mix)
{
// Some lines CRLF, some LF — should all parse.
string body =
"aaaa.onion:24112\r\n"
"bbbb.onion:24112\n"
"cccc.onion:24112\r\n";
vector<string> out = ParseSeedListBody(body);
BOOST_CHECK_EQUAL(out.size(), 3u);
}
// ═══════════════════════════════════════════════════════════════════════════════
// IsValidSocksNegotiationTimeout — exact boundaries and just outside
// ═══════════════════════════════════════════════════════════════════════════════
BOOST_AUTO_TEST_CASE(timeout_just_below_lower_bound)
{
BOOST_CHECK(!IsValidSocksNegotiationTimeout(4999));
}
BOOST_AUTO_TEST_CASE(timeout_exact_lower_bound)
{
BOOST_CHECK(IsValidSocksNegotiationTimeout(5000));
}
BOOST_AUTO_TEST_CASE(timeout_default)
{
BOOST_CHECK(IsValidSocksNegotiationTimeout(60000));
}
BOOST_AUTO_TEST_CASE(timeout_exact_upper_bound)
{
BOOST_CHECK(IsValidSocksNegotiationTimeout(180000));
}
BOOST_AUTO_TEST_CASE(timeout_just_above_upper_bound)
{
BOOST_CHECK(!IsValidSocksNegotiationTimeout(180001));
}
BOOST_AUTO_TEST_CASE(timeout_zero)
{
BOOST_CHECK(!IsValidSocksNegotiationTimeout(0));
}
BOOST_AUTO_TEST_CASE(timeout_negative)
{
BOOST_CHECK(!IsValidSocksNegotiationTimeout(-1));
}
BOOST_AUTO_TEST_CASE(timeout_max_int)
{
// Guard against wraparound on int boundaries.
BOOST_CHECK(!IsValidSocksNegotiationTimeout(2147483647));
}
BOOST_AUTO_TEST_CASE(timeout_midrange)
{
// Several plausible values in the middle of the range.
BOOST_CHECK(IsValidSocksNegotiationTimeout(10000));
BOOST_CHECK(IsValidSocksNegotiationTimeout(30000));
BOOST_CHECK(IsValidSocksNegotiationTimeout(120000));
}
// ═══════════════════════════════════════════════════════════════════════════════
// Integration: dechunked body → seed-list parser round-trip
// ═══════════════════════════════════════════════════════════════════════════════
BOOST_AUTO_TEST_CASE(roundtrip_chunked_then_parsed)
{
// Build a chunked-encoded seed list, decode it, then parse the result.
string seedBody =
"aaaa.onion:24112\n"
"bbbb.onion:24112\n"
"# cccc is the backup\n"
"cccc.onion:24112,dddd.onion:24112\n";
// Encode into chunked form.
string chunked;
size_t pos = 0;
while (pos < seedBody.size()) {
size_t take = min(seedBody.size() - pos, (size_t)16);
char hex[16];
snprintf(hex, sizeof(hex), "%zx", take);
chunked += string(hex) + "\r\n" + seedBody.substr(pos, take) + "\r\n";
pos += take;
}
chunked += "0\r\n\r\n";
string decoded;
BOOST_REQUIRE_EQUAL(DechunkTransferEncoding(chunked, decoded), DECHUNK_OK);
BOOST_CHECK_EQUAL(decoded, seedBody);
vector<string> out = ParseSeedListBody(decoded);
BOOST_CHECK_EQUAL(out.size(), 4u);
BOOST_CHECK_EQUAL(out[0], "aaaa.onion:24112");
BOOST_CHECK_EQUAL(out[1], "bbbb.onion:24112");
BOOST_CHECK_EQUAL(out[2], "cccc.onion:24112");
BOOST_CHECK_EQUAL(out[3], "dddd.onion:24112");
}
BOOST_AUTO_TEST_SUITE_END()
+218
View File
@@ -0,0 +1,218 @@
// Copyright (c) 2026 Cryptographic Triangles
// Distributed under the MIT/X11 software license, see the accompanying
// file COPYING or http://www.opensource.org/licenses/mit-license.php.
#include <boost/test/unit_test.hpp>
#include "util.h"
#include "onionseed.h"
#include <openssl/evp.h>
#include <openssl/sha.h>
#include <string>
#include <vector>
// ═══════════════════════════════════════════════════════════════════════════════
// v3 Onion Address Checksum Validation Tests
// ═══════════════════════════════════════════════════════════════════════════════
//
// Background: 2026-06-21 from-zero sync test produced 4,842 Tor
// "No more HSDir available" errors and 181 "ed25519 validation failed"
// warnings. Root cause: a 1-character transposition (btb6 vs gtb6) in the
// test config's vmepp seed address. Tor correctly rejected the corrupted
// address but the error wasn't surfaced to a place where an operator would
// notice — the daemon just kept trying.
//
// These tests run the same v3 hidden service checksum validation that Tor
// runs internally, so we catch corruption at build/CI time instead of at
// daemon runtime.
//
// v3 onion = base32( PUBKEY(32) || CHECKSUM(2) || VERSION(1) )
// PUBKEY = 32-byte ed25519 public key
// CHECKSUM = SHA3-256( ".onion checksum" || PUBKEY || VERSION )[:2]
// VERSION = 0x03
// Total decoded = 35 bytes, base32-encoded to 56 chars + ".onion" suffix
// ═══════════════════════════════════════════════════════════════════════════════
namespace {
static const std::string V3_CHECKSUM_INPUT = ".onion checksum";
static const size_t V3_PUBKEY_LENGTH = 32;
static const size_t V3_DECODED_LENGTH = 35;
/** Compute SHA3-256 of a byte vector. */
std::vector<unsigned char> sha3_256(const std::vector<unsigned char>& data) {
std::vector<unsigned char> out(EVP_MAX_MD_SIZE);
unsigned int out_len = 0;
EVP_MD_CTX* ctx = EVP_MD_CTX_new();
EVP_DigestInit_ex(ctx, EVP_sha3_256(), nullptr);
EVP_DigestUpdate(ctx, data.data(), data.size());
EVP_DigestFinal_ex(ctx, out.data(), &out_len);
EVP_MD_CTX_free(ctx);
out.resize(out_len);
return out;
}
/**
* Validate a v3 onion address against the hidden service checksum.
*
* @return true if address is a valid v3 onion, false otherwise
*/
bool IsValidV3Onion(const std::string& address) {
// Length check
if (address.size() != 62) return false; // 56 + ".onion" (6)
if (address.substr(56) != ".onion") return false;
// Base32 alphabet check (lowercase a-z + 2-7)
for (size_t i = 0; i < 56; i++) {
char c = address[i];
if (!((c >= 'a' && c <= 'z') || (c >= '2' && c <= '7'))) return false;
}
// Base32 decode
std::string padded = address.substr(0, 56);
while (padded.size() % 8 != 0) padded += '=';
bool invalid = false;
std::vector<unsigned char> decoded = DecodeBase32(padded.c_str(), &invalid);
if (invalid) return false;
if (decoded.size() != V3_DECODED_LENGTH) return false;
// v3 spec: PUBKEY(32) || CHECKSUM(2) || VERSION(1)
const unsigned char* pubkey = &decoded[0];
const unsigned char* checksum = &decoded[32];
unsigned char version = decoded[34];
if (version != 0x03) return false;
// Compute expected checksum: SHA3-256( ".onion checksum" || pubkey || version )[:2]
std::vector<unsigned char> input;
input.insert(input.end(), V3_CHECKSUM_INPUT.begin(), V3_CHECKSUM_INPUT.end());
input.insert(input.end(), pubkey, pubkey + V3_PUBKEY_LENGTH);
input.push_back(version);
std::vector<unsigned char> hash = sha3_256(input);
return checksum[0] == hash[0] && checksum[1] == hash[1];
}
/** Helper to count the number of .onion entries in the hardcoded seed array. */
size_t CountOnionSeeds() {
size_t count = 0;
for (int i = 0; strMainNetOnionSeed[i][0] != nullptr; i++) {
count++;
}
return count;
}
} // anonymous namespace
BOOST_AUTO_TEST_SUITE(onion_v3_tests)
BOOST_AUTO_TEST_CASE(onion_v3_valid_known_seeds)
{
// The 7 hardcoded seeds in src/onionseed.h MUST all be valid v3 onions.
// If any of these fail, Tor will reject them at runtime.
for (int i = 0; strMainNetOnionSeed[i][0] != nullptr; i++) {
std::string addr = strMainNetOnionSeed[i][0];
std::string full = addr + ".onion";
BOOST_CHECK_MESSAGE(
IsValidV3Onion(full),
"Hardcoded seed #" << i << " is not a valid v3 onion: " << full
);
}
}
BOOST_AUTO_TEST_CASE(onion_v3_detects_transposition)
{
// The exact corruption found on 2026-06-21:
// Test config: vmepp7plxngv4qpyngbbtb6... (btb6, CORRUPT)
// Source/prod: vmepp7plxngv4qpyngbgtb6... (gtb6, valid)
const std::string VALID = "vmepp7plxngv4qpyngbgtb6njwnmlwy4api64xnwkhaf6fm3qlqtpfad.onion";
const std::string CORRUPT_BTB6 = "vmepp7plxngv4qpyngbbtb6njwnmlwy4api64xnwkhaf6fm3qlqtpfad.onion";
BOOST_CHECK_MESSAGE(IsValidV3Onion(VALID), "gtb6 variant should be valid");
BOOST_CHECK_MESSAGE(!IsValidV3Onion(CORRUPT_BTB6),
"btb6 variant should be REJECTED (this was the live bug)");
}
BOOST_AUTO_TEST_CASE(onion_v3_detects_wrong_length)
{
// 55 chars (1 short) — should be rejected
BOOST_CHECK(!IsValidV3Onion("a2z4m7dqzmcsyj4i6a5kpj4txr3c7yqt2qf3kzqgj5uhwnad6b3a.onio"));
// 57 chars (1 long) — should be rejected
BOOST_CHECK(!IsValidV3Onion("a2z4m7dqzmcsyj4i6a5kpj4txr3c7yqt2qf3kzqgj5uhwnad6b3aaaa.onion"));
// Empty — should be rejected
BOOST_CHECK(!IsValidV3Onion(""));
}
BOOST_AUTO_TEST_CASE(onion_v3_detects_missing_suffix)
{
const std::string no_suffix = "a2z4m7dqzmcsyj4i6a5kpj4txr3c7yqt2qf3kzqgj5uhwnad6b3a";
BOOST_CHECK(!IsValidV3Onion(no_suffix));
const std::string wrong_suffix = "a2z4m7dqzmcsyj4i6a5kpj4txr3c7yqt2qf3kzqgj5uhwnad6b3a.com";
BOOST_CHECK(!IsValidV3Onion(wrong_suffix));
}
BOOST_AUTO_TEST_CASE(onion_v3_detects_invalid_base32)
{
// '0' and '1' are not in the base32 alphabet
BOOST_CHECK(!IsValidV3Onion("0123456789abcdefghijklmnopqrstuvwxyz0123456789abcdefghijkl.onion"));
// '8' and '9' are not in the base32 alphabet
BOOST_CHECK(!IsValidV3Onion("89abcdefghijklmnopqrstuvwxyz23456789abcdefghijklmnopqrstuvwx.onion"));
// Uppercase should be rejected (we expect lowercase)
BOOST_CHECK(!IsValidV3Onion("A2Z4M7DQZMCSYJ4I6A5KPJ4TXR3C7YQT2QF3KZQGJ5UHWNAD6B3A.onion"));
}
BOOST_AUTO_TEST_CASE(onion_v3_detects_bad_version_byte)
{
// Construct an address with a non-v3 version byte by modifying the last
// char (which encodes the version byte's last 5 bits). For our purposes,
// we just need to verify that some random valid-looking string is rejected.
// The address below has the right length and valid base32, but its
// checksum bytes (derived from a fake pubkey) won't match the SHA3-256
// of that fake pubkey.
const std::string fake = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.onion";
BOOST_CHECK_MESSAGE(!IsValidV3Onion(fake),
"Address with all-'a' body should have invalid checksum");
}
BOOST_AUTO_TEST_CASE(onion_v3_round_trip_encoding)
{
// Encode and decode a known 35-byte input, verify the encoding is
// deterministic. This protects against accidental changes to the
// base32 implementation that could silently break v3 onion generation.
unsigned char input[35];
for (int i = 0; i < 35; i++) input[i] = (unsigned char)(i * 7 + 13);
std::string encoded = EncodeBase32(input, 35);
BOOST_CHECK_EQUAL(encoded.size(), 56u);
bool invalid = false;
std::vector<unsigned char> decoded = DecodeBase32(encoded.c_str(), &invalid);
BOOST_CHECK(!invalid);
BOOST_CHECK_EQUAL(decoded.size(), 35u);
for (int i = 0; i < 35; i++) {
BOOST_CHECK_EQUAL(decoded[i], input[i]);
}
}
BOOST_AUTO_TEST_CASE(onion_v3_audit_summary)
{
// Top-level summary: how many seeds are in onionseed.h
size_t n = CountOnionSeeds();
BOOST_CHECK_MESSAGE(n >= 1, "Expected at least 1 hardcoded seed, found " << n);
// All of them must validate
int nValid = 0, nInvalid = 0;
for (int i = 0; strMainNetOnionSeed[i][0] != nullptr; i++) {
if (IsValidV3Onion(std::string(strMainNetOnionSeed[i][0]) + ".onion")) {
nValid++;
} else {
nInvalid++;
}
}
BOOST_CHECK_EQUAL(nInvalid, 0);
BOOST_CHECK_EQUAL((size_t)nValid, n);
}
BOOST_AUTO_TEST_SUITE_END()
+7
View File
@@ -2369,6 +2369,13 @@ void CTorV3Manager::HandleWalletAddrResponse(CNode* pfrom, const std::string& tr
// Signature valid — cache the mapping
CacheOnionAddress(peerOnion, triAddr);
// Signed peer bonus: this peer cryptographically proved they own their
// .onion via the walletaddr handshake. Mark them as a signed peer so
// syncmanager peer selection (syncmanager.cpp:495) prefers them.
pfrom->nSignedPeerBonus = 1;
printf("SYNC-SIGN: marked %s as signed peer (proved identity via walletaddr)\n",
peerOnion.c_str());
// Fire any pending resolve callbacks
std::function<void(bool, const std::string&)> callback;
{