Compare commits
3 Commits
c702a77c07
...
7414ce1e29
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7414ce1e29 | ||
|
|
9bd15de65b | ||
|
|
69b0e726b8 |
@@ -4,3 +4,4 @@
|
||||
- [User profile](user-profile.md) — who Dobromir is and how to work with him
|
||||
- [Project status](project-status.md) — 35/35 stories done; alpha hardening next
|
||||
- **Alpha hardening** — `.scratch/alpha-hardening/` (22 issues, ADRs 0016–0019, [README](../.scratch/alpha-hardening/README.md), [handoff](../.scratch/alpha-hardening/handoff.md))
|
||||
- [Alpha hardening navigation](alpha-hardening-navigation.md) — locked fraud/auth decisions, Bucket-1 order, handoff pointers
|
||||
|
||||
18
.claude/memory/alpha-hardening-navigation.md
Normal file
18
.claude/memory/alpha-hardening-navigation.md
Normal file
@@ -0,0 +1,18 @@
|
||||
---
|
||||
name: alpha-hardening-navigation
|
||||
description: Where the alpha-hardening plan lives, locked design decisions, and implementation order
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
---
|
||||
|
||||
Active workstream (started 2026-07-04): alpha hardening of the money/trust path. Full handoff at `/mnt/c/Users/popov/Downloads/neuron-tai-alpha-handoff-2026-07-04.md` (note: its "planning artifacts missing" section is stale — ADRs 0016–0019 and `.scratch/alpha-hardening/issues/` were created in commit 68e0572).
|
||||
|
||||
**Navigation:** `.scratch/alpha-hardening/README.md` = index + phase order; `research-verifiable-inference.md` §8 = layered fraud scheme, §9 = build-vs-adopt; ADR-0018 = flagship fraud design; `docs/agents/issue-tracker.md` = issue conventions (active work in `.scratch/<slug>/`).
|
||||
|
||||
**Locked decisions (do not re-derive):** TOPLOC ADOPT (`pip install toploc`, teacher-forced prefill, one canonical precision per model); audit 5% default escalating on anomaly/low-rep/disputes; blame via on-demand per-hop activation commitments + bisection (fixes validator `_final_text_node` bug — it blames only the last hop); reputation = persisted graduated ×0.8-per-strike multiplier affecting routing + audit rate; full pending forfeiture stays the primary penalty; accounting becomes tracker-authoritative (count tokens from the proxied stream, work units from tracker-assigned spans — node self-reports are untrusted); strikes/bans/reputation must survive restart (RegistryWallet is RAM-only today); multi-tracker money fixes (C2/C3/C4/A3/H1) designed in ADR-0019 but implementation deferred.
|
||||
|
||||
**Implementation order (Bucket 1):** 1) A2+C1+validator-token unified auth boundary + gossip auth (issues 02, 01, 20) → 2) A1/A5 persist strike/ban/reputation (05) → 3) C5+M1 starting credit 0 + spend cap (03) → 4) H2 tracker-authoritative accounting (04) → 5) C6 wallet binding proof (11) → fraud arc (06–10).
|
||||
|
||||
**Why:** three audits agreed the alpha blockers are unauthenticated gossip (anyone can inject billing events), the free-credit faucet, and ephemeral bans.
|
||||
**How to apply:** work test-first per issue acceptance criteria; use `.venv`; `cryptography` belongs in node deps (wallet.py imports it — causes many of the 24 "failures" in a fresh env). See [[project-status]] and [[autonomous-work-style]].
|
||||
@@ -27,6 +27,8 @@ Suite: 222 passed, 3 skipped (openai/langchain packages missing in .venv — pre
|
||||
|
||||
Planning complete in `.scratch/alpha-hardening/`: research report, ADRs 0016–0019, 22 issue files, README + handoff. **Bucket 1 trust-boundary blockers** (auth, persistence, starting credit, accounting, wallet binding) are next before fraud arc (TOPLOC, bisection, reputation routing). Prod audit thresholds gated on issue 21 (honest-noise calibration corpus).
|
||||
|
||||
Historical handoff note: `/mnt/c/Users/popov/Downloads/neuron-tai-alpha-handoff-2026-07-04.md` is useful for navigation and original audit context, but it predates the completed `.scratch/alpha-hardening/` planning artifacts. Treat its "missing ADR/issues/README" statements as stale; prefer `.scratch/alpha-hardening/README.md` and `.scratch/alpha-hardening/handoff.md` for current task order.
|
||||
|
||||
## Windows CUDA node (working as of 2026-07-01)
|
||||
- miniforge3 base env, torch 2.7.1+cu118, torchvision 0.22.x+cu118
|
||||
- RTX 4060 Laptop GPU, 8 GB VRAM, benchmark index ~11,200
|
||||
|
||||
@@ -26,17 +26,18 @@ Locked scope: one settlement tracker, open node join, devnet mock-USDT, reputati
|
||||
|
||||
## Recommended implementation order
|
||||
|
||||
**Implement Bucket 1 first.** Fraud arc depends on **auth (01–02, 20)** and **persistence (05)**.
|
||||
**Implement Bucket 1 first.** Fraud arc depends on **auth foundation (02 + 20)**, **hive gossip enforcement (01)**, and **persistence (05)**.
|
||||
|
||||
### Phase 1 — Trust boundary (alpha blockers)
|
||||
|
||||
| Order | Issue | ID | Depends on |
|
||||
|---|---|---|---|
|
||||
| 1 | [Unified auth + gossip auth](./issues/02-a2-unified-auth-boundary.md) + [C1 gossip](./issues/01-c1-gossip-auth.md) + [Validator service token](./issues/20-validator-service-token.md) | A2, C1, — | — |
|
||||
| 2 | [Persist strike/ban/reputation](./issues/05-a1-a5-persist-strike-ban-reputation.md) | A1/A5 | 02 |
|
||||
| 3 | [Starting credit 0 + spend cap](./issues/03-c5-starting-credit-zero.md) | C5, M1 | 02 |
|
||||
| 4 | [Tracker-authoritative accounting](./issues/04-h2-tracker-authoritative-accounting.md) | H2 | 02 |
|
||||
| 5 | [Wallet binding proof](./issues/11-c6-wallet-binding-proof.md) | C6 | 02, 03 |
|
||||
| 1 | [Unified auth boundary](./issues/02-a2-unified-auth-boundary.md) + [Validator service token](./issues/20-validator-service-token.md) | A2, — | — |
|
||||
| 2 | [C1 hive gossip auth enforcement](./issues/01-c1-gossip-auth.md) | C1 | 02 |
|
||||
| 3 | [Persist strike/ban/reputation](./issues/05-a1-a5-persist-strike-ban-reputation.md) | A1/A5 | 02 |
|
||||
| 4 | [Starting credit 0 + spend cap](./issues/03-c5-starting-credit-zero.md) | C5, M1 | 02 |
|
||||
| 5 | [Tracker-authoritative accounting](./issues/04-h2-tracker-authoritative-accounting.md) | H2 | 02 |
|
||||
| 6 | [Wallet binding proof](./issues/11-c6-wallet-binding-proof.md) | C6 | 02, 03 |
|
||||
|
||||
### Phase 2 — Fraud arc (after Phase 1)
|
||||
|
||||
@@ -67,14 +68,14 @@ Locked scope: one settlement tracker, open node join, devnet mock-USDT, reputati
|
||||
| [17 Duplicate US-020 dedup](./issues/17-doc-duplicate-us020-dedup.md) |
|
||||
| [18 Operational runbooks](./issues/18-doc-operational-runbooks.md) |
|
||||
| [19 Cryptography + test env](./issues/19-doc-cryptography-test-env.md) |
|
||||
| [22 MEMORY + project-status index](./issues/22-doc-memory-project-status.md) |
|
||||
| [22 MEMORY + project-status index](./issues/22-doc-memory-project-status.md) (done) |
|
||||
| [21 Honest-noise calibration corpus](./issues/21-honest-noise-calibration-corpus.md) (ops; prod gate for audits) |
|
||||
|
||||
## First 3 to implement
|
||||
|
||||
1. **01 + 02 + 20** — Gossip auth + unified auth boundary + validator service token (unblocks financial endpoints)
|
||||
2. **05** — Persist strike/ban/reputation (penalties must survive restart)
|
||||
3. **03** — Starting credit 0 + funded-account gate (closes free-credit faucet)
|
||||
1. **02 + 20** — Unified auth boundary + validator service token (shared helper and roles)
|
||||
2. **01** — Apply hive auth to billing/accounts/stats gossip endpoints
|
||||
3. **05** — Persist strike/ban/reputation (penalties must survive restart)
|
||||
|
||||
## Research anchor
|
||||
|
||||
|
||||
@@ -67,9 +67,9 @@ See `.scratch/alpha-hardening/README.md` for full table.
|
||||
|
||||
**First 3:**
|
||||
|
||||
1. **01 + 02 + 20** — Gossip auth + unified auth boundary + validator service token
|
||||
2. **05** — Persist strike/ban/reputation
|
||||
3. **03** — Starting credit 0 + funded-account gate
|
||||
1. **02 + 20** — Unified auth boundary + validator service token
|
||||
2. **01** — Apply hive auth to billing/accounts/stats gossip endpoints
|
||||
3. **05** — Persist strike/ban/reputation
|
||||
|
||||
**Prod gate:** issue **21** (honest-noise corpus) before enabling production audit thresholds.
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ Add authenticated peer identity to all tracker gossip mutation endpoints. Today
|
||||
- `packages/tracker/meshnet_tracker/billing.py` — `apply_events` (~301–311)
|
||||
- `packages/tracker/meshnet_tracker/accounts.py` — `apply_events` (~220–226)
|
||||
|
||||
Implement per ADR-0017 §3: shared hive HMAC (body + timestamp) or mutual TLS between configured tracker peers. Reject unauthenticated gossip with 401.
|
||||
Implement per ADR-0017 §3 using the auth helper/config from issue 02: shared hive HMAC (body + timestamp) or mutual TLS between configured tracker peers. Reject unauthenticated gossip with 401.
|
||||
|
||||
**Note:** `/v1/gossip` (node throughput fan-out, `server.py` ~1331) is **not** in scope for this issue — see ADR-0017 §3 out-of-scope note.
|
||||
|
||||
@@ -22,7 +22,7 @@ Implement per ADR-0017 §3: shared hive HMAC (body + timestamp) or mutual TLS be
|
||||
|
||||
1. Red: unauthenticated POST to `/v1/billing/gossip` applies a credit event today — test must fail after fix.
|
||||
2. Red: authenticated peer with valid HMAC applies events; invalid/missing auth returns 401 and `applied: 0`.
|
||||
3. Green: implement verifier + config (`--hive-secret` or peer cert paths).
|
||||
3. Green: wire the issue-02 verifier/config (`--hive-secret` or peer cert paths) into the three hive mutation endpoints.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
@@ -38,4 +38,4 @@ Implement per ADR-0017 §3: shared hive HMAC (body + timestamp) or mutual TLS be
|
||||
|
||||
## Blocked by
|
||||
|
||||
None — implement alongside or immediately before `02-a2-unified-auth-boundary.md`.
|
||||
- `02-a2-unified-auth-boundary.md` — owns shared auth middleware/config. Implement in the same PR if simpler.
|
||||
|
||||
@@ -4,7 +4,7 @@ Status: ready-for-agent
|
||||
|
||||
## What to build
|
||||
|
||||
Replace header-presence stubs with a single auth middleware that resolves API keys, admin sessions, validator service tokens, and hive peer identity. Close leaks on financial and operator endpoints.
|
||||
Replace header-presence stubs with a single auth middleware that resolves API keys, admin sessions, validator service tokens, and hive peer identity. Close leaks on financial and operator endpoints. This is the auth foundation issue; issue 01 should only apply hive auth to gossip endpoints once the helper exists.
|
||||
|
||||
**Code refs:**
|
||||
|
||||
@@ -16,7 +16,7 @@ Replace header-presence stubs with a single auth middleware that resolves API ke
|
||||
- `packages/tracker/meshnet_tracker/server.py` — `_session_account` (~2468+), `_handle_admin_accounts` (~2588–2608) — H4
|
||||
- `packages/tracker/meshnet_tracker/accounts.py` — `session_account()`, `create_session()` only (session store; not handler wiring)
|
||||
|
||||
Per ADR-0017 §4: forfeit → validator or admin; benchmark → admin; billing summary/settlements/registry wallets → admin session. Forfeit validator identity: see `20-validator-service-token.md`.
|
||||
Per ADR-0017 §4: forfeit → validator or admin; benchmark → admin; billing summary/settlements/registry wallets → admin session. Include the validator service token shape from `20-validator-service-token.md` in the same implementation if practical.
|
||||
|
||||
## Test-first
|
||||
|
||||
@@ -27,6 +27,7 @@ Per ADR-0017 §4: forfeit → validator or admin; benchmark → admin; billing s
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Single `_require_auth(role=...)` (or equivalent) used by all privileged handlers
|
||||
- [ ] Shared auth config supports admin sessions, validator service token, and hive peer HMAC/mTLS
|
||||
- [ ] Forfeit accepts only validator service token or admin session — not arbitrary Bearer strings
|
||||
- [ ] Financial read endpoints require admin session (alpha posture)
|
||||
- [ ] Benchmark write/read require admin or service token
|
||||
@@ -38,8 +39,8 @@ Per ADR-0017 §4: forfeit → validator or admin; benchmark → admin; billing s
|
||||
|
||||
## Related
|
||||
|
||||
- `20-validator-service-token.md` — validator service token format, rotation, forfeit auth
|
||||
- `20-validator-service-token.md` — checklist for validator service token format, rotation, forfeit auth
|
||||
|
||||
## Blocked by
|
||||
|
||||
- `01-c1-gossip-auth.md` (shared auth config)
|
||||
None. This issue should land before `01-c1-gossip-auth.md`.
|
||||
|
||||
@@ -4,7 +4,7 @@ Status: ready-for-agent
|
||||
|
||||
## What to build
|
||||
|
||||
Stop trusting node-reported usage for billing. The tracker already proxies responses — count tokens from the proxied stream/body and compute work units from the **route it constructed**, not node declarations.
|
||||
Stop trusting node-reported usage for billing. The tracker already proxies responses — use tracker-observed response data and request limits to cap billable tokens, and compute work units from the **route it constructed**, not node declarations.
|
||||
|
||||
**Code refs:**
|
||||
|
||||
@@ -17,13 +17,14 @@ Accounting fraud = inflating tokens or shard span. Per ADR-0018 §5.
|
||||
|
||||
## Test-first
|
||||
|
||||
1. Red: mock upstream returns inflated `usage.total_tokens` in body but tracker bills that value — test expects tracker-measured count.
|
||||
1. Red: mock upstream returns inflated `usage.total_tokens` in body but tracker bills that value — test expects the tracker to cap billable tokens from observed stream chunks or request bounds.
|
||||
2. Red: node registers false `shard_end`; billing uses tracker route span, not registration field alone.
|
||||
3. Green: authoritative counters; ignore node-reported work units on charge path.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Token count for billing derived from tracker-parsed proxy response — **prefer tracker-measured stream chunk count**; when upstream `usage.total_tokens` is present, use the **minimum** of tracker count and node-reported value (cap inflated node usage)
|
||||
- [ ] Streaming token count uses tracker-observed chunks/tokens; upstream `usage.total_tokens` can only lower or match that observed count, never inflate it
|
||||
- [ ] Non-streaming token count caps upstream `usage.total_tokens` by tracker-known request bounds (`max_tokens`, and prompt estimate if available); exact tokenizer-backed counts are deferred unless already available locally
|
||||
- [ ] Work units = tracker-computed layer span per hop at route build time (~1781–1782)
|
||||
- [ ] Nodes cannot increase payout by lying about shard range mid-request
|
||||
- [ ] Integration test: malicious node metadata does not inflate `charge_request` shares
|
||||
|
||||
@@ -6,12 +6,12 @@ Status: ready-for-agent
|
||||
|
||||
Adopt [TOPLOC](https://github.com/PrimeIntellect-ai/toploc) (MIT, `pip install toploc`) for activation fingerprint commit and verify. Replace string-equality validator checks with teacher-forced prefill + TOPLOC tolerance matching.
|
||||
|
||||
**Estimated effort:** 2+ sessions — split into subtasks below without separate issue files.
|
||||
**Estimated effort:** 2+ sessions. First landing should be the validator-only TOPLOC primitive and docs; node runtime commitments/on-demand capture can follow in issue 07 if this grows.
|
||||
|
||||
| Subtask | Owner package | Deliverable |
|
||||
|---|---|---|
|
||||
| Validator audit primitive | `packages/validator/` | Teacher-forced prefill, TOPLOC verify, unit tests with stub tensors |
|
||||
| Node runtime commitments | `packages/node/` (if prover-side) | On-demand activation fingerprint generation on audit-selected requests |
|
||||
| Node runtime commitments | `packages/node/` (if prover-side) | On-demand activation fingerprint generation on audit-selected requests; move to issue 07 if it blocks the validator primitive |
|
||||
|
||||
**Code refs:**
|
||||
|
||||
|
||||
@@ -17,13 +17,13 @@ Implement graduated reputation per ADR-0018 §6: score derives only from tracker
|
||||
|
||||
## Test-first
|
||||
|
||||
1. Red: no reputation field — add schema + default 1.0 for new wallets.
|
||||
2. Green: clean audit +0.05 (tunable); failed audit −0.3 and strike; three strikes → ban persisted.
|
||||
1. Red: persisted reputation/strike fields from issue 05 are ignored by scoring/routing today.
|
||||
2. Green: clean audit +0.05 (tunable); failed audit −0.3 and strike; three strikes → ban persisted via issue-05 fields.
|
||||
3. Inactivity decay after N days without completed jobs.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] `reputation_score` persisted in registry SQLite (issue 05)
|
||||
- [ ] Uses `reputation_score` and strike/ban fields persisted by issue 05; does not introduce a second schema path
|
||||
- [ ] Audit pass/fail updates score with documented deltas
|
||||
- [ ] Strike applies ×0.8 multiplier to routing weight (separate from forfeiture amount)
|
||||
- [ ] Ban at 3 strikes; probation job count still enforced
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
Status: ready-for-agent
|
||||
Status: ready-for-human
|
||||
|
||||
# 17 — DOC: Duplicate US-020 issue dedup
|
||||
|
||||
@@ -20,4 +20,4 @@ Resolve numbering collision without losing history.
|
||||
|
||||
## Blocked by
|
||||
|
||||
Human approval for renumbering
|
||||
Human approval for renumbering. An agent may prepare the mapping note, but must not run `git mv` or rewrite cross-links until the canonical number is approved.
|
||||
|
||||
@@ -4,7 +4,7 @@ Status: ready-for-agent
|
||||
|
||||
## What to build
|
||||
|
||||
Document and verify test/dev environment setup for wallet crypto paths. `packages/node/meshnet_node/wallet.py` uses `cryptography`; failures occur when `.venv` lacks deps.
|
||||
Document and verify test/dev environment setup for wallet crypto paths. `packages/node/meshnet_node/wallet.py` uses `cryptography`; failures occur when `.venv` lacks deps. `cryptography>=41` is already declared in `packages/node/pyproject.toml`, so this issue should focus on documenting the editable-install path and only add root/dev extras if tests still import the node wallet without installing the node package.
|
||||
|
||||
**Code refs:**
|
||||
|
||||
@@ -14,8 +14,8 @@ Document and verify test/dev environment setup for wallet crypto paths. `package
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Confirm `cryptography` in node package deps; add to root/dev extras if tests import wallet without node install
|
||||
- [ ] Add short **Test environment** section to `CONTRIBUTING.md` or `docs/dev/test-env.md`: use `.venv/Scripts/python.exe`, `pip install -e packages/node ...`, optional dep skips
|
||||
- [ ] Confirm `cryptography>=41` remains in node package deps; add to root/dev extras only if tests import wallet without node install
|
||||
- [ ] Add short **Test environment** section to `docs/dev/test-env.md` (or `CONTRIBUTING.md` if created): use `.venv/Scripts/python.exe`, `pip install -e packages/node ...`, optional dep skips
|
||||
- [ ] Note which tests require optional deps (`--ignore=test_openai_gateway,...`)
|
||||
- [ ] No unrelated production code changes
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ Status: ready-for-agent
|
||||
|
||||
## What to build
|
||||
|
||||
Define and implement a **validator service token** distinct from client API keys and admin sessions. The validator process must authenticate when calling `POST /v1/billing/forfeit`; arbitrary Bearer strings and client API keys must be rejected.
|
||||
Define and implement a **validator service token** distinct from client API keys and admin sessions. The validator process must authenticate when calling `POST /v1/billing/forfeit`; arbitrary Bearer strings and client API keys must be rejected. This is a checklist subtask for issue 02 and should normally land in the same PR as the unified auth middleware.
|
||||
|
||||
Per [ADR-0017 §4](../../docs/adr/0017-tracker-authentication-and-authorization.md): forfeit accepts **validator service identity or admin session** only.
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
Status: ready-for-agent
|
||||
Status: done
|
||||
|
||||
# 22 — DOC: MEMORY.md + project-status alpha-hardening index
|
||||
|
||||
@@ -8,9 +8,9 @@ Update persistent memory files so agents and humans find the alpha-hardening fea
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] `.claude/memory/MEMORY.md` — index entry for alpha-hardening (`.scratch/alpha-hardening/`, ADRs 0016–0019, issue count)
|
||||
- [ ] `.claude/memory/project-status.md` — brief alpha-hardening section: planning complete, Bucket 1 blockers next, link README
|
||||
- [ ] Cross-link `.scratch/alpha-hardening/handoff.md` from README (not temp path)
|
||||
- [x] `.claude/memory/MEMORY.md` — index entry for alpha-hardening (`.scratch/alpha-hardening/`, ADRs 0016–0019, issue count)
|
||||
- [x] `.claude/memory/project-status.md` — brief alpha-hardening section: planning complete, Bucket 1 blockers next, link README
|
||||
- [x] Cross-link `.scratch/alpha-hardening/handoff.md` from README (not temp path)
|
||||
|
||||
## ADR links
|
||||
|
||||
@@ -18,4 +18,8 @@ Update persistent memory files so agents and humans find the alpha-hardening fea
|
||||
|
||||
## Blocked by
|
||||
|
||||
None — parallel with other Bucket 3 doc issues
|
||||
None — completed
|
||||
|
||||
## Comments
|
||||
|
||||
2026-07-04 triage: already satisfied by `.claude/memory/MEMORY.md`, `.claude/memory/project-status.md`, and `.scratch/alpha-hardening/README.md`.
|
||||
|
||||
75
packages/tracker/meshnet_tracker/auth.py
Normal file
75
packages/tracker/meshnet_tracker/auth.py
Normal file
@@ -0,0 +1,75 @@
|
||||
"""Unified tracker auth primitives (ADR-0017, alpha issues 01/02/20).
|
||||
|
||||
Two mechanisms live here:
|
||||
|
||||
- **Hive peer HMAC** — gossip mutation endpoints require a signature computed
|
||||
as HMAC-SHA256(hive_secret, "<timestamp>." + raw_body). All trackers in a
|
||||
hive share one secret (``--hive-secret`` / MESHNET_HIVE_SECRET). Timestamps
|
||||
outside the skew window are rejected to blunt replay.
|
||||
- **Validator service token** — a dedicated bearer credential distinct from
|
||||
client API keys and admin sessions (``--validator-service-token`` /
|
||||
MESHNET_VALIDATOR_SERVICE_TOKEN), resolved to the ``validator`` role.
|
||||
|
||||
Role resolution itself happens in the request handler (it needs the account
|
||||
store); these are the shared, dependency-free pieces.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import hashlib
|
||||
import time
|
||||
|
||||
HIVE_SIGNATURE_HEADER = "X-Meshnet-Hive-Signature"
|
||||
HIVE_TIMESTAMP_HEADER = "X-Meshnet-Hive-Timestamp"
|
||||
HIVE_MAX_CLOCK_SKEW_SECONDS = 300.0
|
||||
|
||||
|
||||
def _hive_digest(secret: str, timestamp: str, body: bytes) -> str:
|
||||
return hmac.new(
|
||||
secret.encode(), timestamp.encode() + b"." + body, hashlib.sha256
|
||||
).hexdigest()
|
||||
|
||||
|
||||
def sign_hive_request(secret: str, body: bytes, *, timestamp: float | None = None) -> dict[str, str]:
|
||||
"""Headers a tracker attaches when pushing gossip to a hive peer."""
|
||||
ts = f"{timestamp if timestamp is not None else time.time():.3f}"
|
||||
return {
|
||||
HIVE_SIGNATURE_HEADER: _hive_digest(secret, ts, body),
|
||||
HIVE_TIMESTAMP_HEADER: ts,
|
||||
}
|
||||
|
||||
|
||||
def verify_hive_request(
|
||||
secret: str | None,
|
||||
headers,
|
||||
body: bytes,
|
||||
*,
|
||||
now: float | None = None,
|
||||
) -> bool:
|
||||
"""True only when the request carries a fresh, valid hive signature.
|
||||
|
||||
Fails closed: no configured secret means no gossip is accepted.
|
||||
"""
|
||||
if not secret:
|
||||
return False
|
||||
signature = headers.get(HIVE_SIGNATURE_HEADER)
|
||||
timestamp = headers.get(HIVE_TIMESTAMP_HEADER)
|
||||
if not signature or not timestamp:
|
||||
return False
|
||||
try:
|
||||
ts_value = float(timestamp)
|
||||
except ValueError:
|
||||
return False
|
||||
current = now if now is not None else time.time()
|
||||
if abs(current - ts_value) > HIVE_MAX_CLOCK_SKEW_SECONDS:
|
||||
return False
|
||||
return hmac.compare_digest(signature, _hive_digest(secret, timestamp, body))
|
||||
|
||||
|
||||
def is_validator_token(token: str | None, configured: str | None) -> bool:
|
||||
"""Constant-time check of a presented bearer token against the configured
|
||||
validator service token."""
|
||||
if not token or not configured:
|
||||
return False
|
||||
return hmac.compare_digest(token, configured)
|
||||
@@ -102,6 +102,22 @@ def main() -> None:
|
||||
default=0.01,
|
||||
help="Never pay out less than this many USDT",
|
||||
)
|
||||
common.add_argument(
|
||||
"--validator-service-token",
|
||||
default=None,
|
||||
help=(
|
||||
"Service token the validator uses on POST /v1/billing/forfeit "
|
||||
"(default: MESHNET_VALIDATOR_SERVICE_TOKEN env; ADR-0017)"
|
||||
),
|
||||
)
|
||||
common.add_argument(
|
||||
"--hive-secret",
|
||||
default=None,
|
||||
help=(
|
||||
"Shared secret authenticating gossip between tracker peers "
|
||||
"(default: MESHNET_HIVE_SECRET env; required for multi-tracker replication)"
|
||||
),
|
||||
)
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="meshnet-tracker",
|
||||
@@ -139,6 +155,8 @@ def main() -> None:
|
||||
settle_period=args.settle_period,
|
||||
payout_threshold=args.payout_threshold,
|
||||
payout_dust_floor=args.payout_dust_floor,
|
||||
validator_service_token=args.validator_service_token,
|
||||
hive_secret=args.hive_secret,
|
||||
)
|
||||
port = server.start()
|
||||
print(f"meshnet-tracker listening on http://{args.host}:{port}", flush=True)
|
||||
|
||||
@@ -75,7 +75,10 @@ const short = (s, n=14) => { s = String(s); return s.length > n ? s.slice(0, 6)
|
||||
|
||||
async function fetchJson(path) {
|
||||
try {
|
||||
const r = await fetch(path);
|
||||
const headers = {};
|
||||
const token = localStorage.getItem("meshnet_session");
|
||||
if (token) headers["Authorization"] = "Bearer " + token;
|
||||
const r = await fetch(path, { headers });
|
||||
if (!r.ok) return null;
|
||||
return await r.json();
|
||||
} catch { return null; }
|
||||
|
||||
@@ -37,6 +37,7 @@ from importlib.resources import files
|
||||
from typing import Any
|
||||
|
||||
from .accounts import DEFAULT_ACCOUNTS_DB_PATH, AccountStore
|
||||
from .auth import is_validator_token, sign_hive_request, verify_hive_request
|
||||
from .billing import DEFAULT_BILLING_DB_PATH, BillingLedger
|
||||
from .gossip import NodeGossip
|
||||
from .raft import RaftNode
|
||||
@@ -1264,6 +1265,8 @@ class _TrackerHTTPServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
|
||||
billing: "BillingLedger | None" = None,
|
||||
accounts: "AccountStore | None" = None,
|
||||
benchmark_results_path: str | None = None,
|
||||
validator_service_token: str | None = None,
|
||||
hive_secret: str | None = None,
|
||||
) -> None:
|
||||
super().__init__(addr, handler)
|
||||
self.registry = registry
|
||||
@@ -1282,6 +1285,8 @@ class _TrackerHTTPServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
|
||||
os.getcwd(), "benchmark_results.json"
|
||||
)
|
||||
self.benchmark_lock = threading.Lock()
|
||||
self.validator_service_token = validator_service_token
|
||||
self.hive_secret = hive_secret
|
||||
|
||||
|
||||
class _TrackerHandler(http.server.BaseHTTPRequestHandler):
|
||||
@@ -1299,6 +1304,65 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
|
||||
except BrokenPipeError:
|
||||
pass
|
||||
|
||||
# ---- unified auth boundary (ADR-0017) ----
|
||||
|
||||
def _resolve_identity(self) -> tuple[str | None, dict | None]:
|
||||
"""Resolve the caller to (role, account).
|
||||
|
||||
Roles: "validator" (service token), "admin"/"user" (session token).
|
||||
Client API keys resolve to no privileged role — they authorize
|
||||
inference and wallet binding only, never operator endpoints.
|
||||
"""
|
||||
server: _TrackerHTTPServer = self.server # type: ignore[assignment]
|
||||
token = _api_key_from_headers(self.headers)
|
||||
if not token:
|
||||
return None, None
|
||||
if is_validator_token(token, server.validator_service_token):
|
||||
return "validator", None
|
||||
if server.accounts is not None:
|
||||
account = server.accounts.session_account(token)
|
||||
if account is not None:
|
||||
return account.get("role", "user"), account
|
||||
return None, None
|
||||
|
||||
def _require_role(self, *allowed: str) -> bool:
|
||||
"""Gate a privileged handler; sends 401/403 and returns False on failure.
|
||||
|
||||
401 when no credential was presented; 403 when a credential was
|
||||
presented but does not resolve to an allowed role — this covers
|
||||
client API keys and garbage bearer strings on operator endpoints.
|
||||
"""
|
||||
role, _account = self._resolve_identity()
|
||||
if role in allowed:
|
||||
return True
|
||||
if _api_key_from_headers(self.headers) is None:
|
||||
self._send_json(401, {"error": "authentication required (admin session or service token)"})
|
||||
else:
|
||||
self._send_json(403, {"error": "this endpoint requires an admin session or service token"})
|
||||
return False
|
||||
|
||||
def _read_hive_authenticated_body(self) -> dict | None:
|
||||
"""Read + verify a hive gossip body (HMAC per ADR-0017 §3).
|
||||
|
||||
Fails closed: without a configured --hive-secret no gossip is
|
||||
accepted. Sends the error response itself and returns None on failure.
|
||||
"""
|
||||
server: _TrackerHTTPServer = self.server # type: ignore[assignment]
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
raw = self.rfile.read(length) if length else b"{}"
|
||||
if not verify_hive_request(server.hive_secret, self.headers, raw):
|
||||
self._send_json(401, {"error": "valid hive signature required"})
|
||||
return None
|
||||
try:
|
||||
body = json.loads(raw or b"{}")
|
||||
except json.JSONDecodeError:
|
||||
self._send_json(400, {"error": "invalid JSON body"})
|
||||
return None
|
||||
if not isinstance(body, dict):
|
||||
self._send_json(400, {"error": "JSON body must be an object"})
|
||||
return None
|
||||
return body
|
||||
|
||||
def _read_json_body(self) -> dict | None:
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
try:
|
||||
@@ -2354,7 +2418,7 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
|
||||
|
||||
def _handle_stats_gossip(self):
|
||||
server: _TrackerHTTPServer = self.server # type: ignore[assignment]
|
||||
body = self._read_json_body()
|
||||
body = self._read_hive_authenticated_body()
|
||||
if body is None:
|
||||
return
|
||||
tracker_url = body.get("tracker_url", "")
|
||||
@@ -2365,6 +2429,8 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
|
||||
|
||||
def _handle_billing_summary(self):
|
||||
server: _TrackerHTTPServer = self.server # type: ignore[assignment]
|
||||
if not self._require_role("admin"):
|
||||
return
|
||||
if server.billing is None:
|
||||
self._send_json(404, {"error": "billing is not enabled on this tracker"})
|
||||
return
|
||||
@@ -2390,6 +2456,8 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
|
||||
|
||||
def _handle_registry_wallets(self):
|
||||
server: _TrackerHTTPServer = self.server # type: ignore[assignment]
|
||||
if not self._require_role("admin"):
|
||||
return
|
||||
if server.contracts is None:
|
||||
self._send_json(200, {"wallets": {}})
|
||||
return
|
||||
@@ -2406,6 +2474,8 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
|
||||
|
||||
def _handle_billing_settlements(self):
|
||||
server: _TrackerHTTPServer = self.server # type: ignore[assignment]
|
||||
if not self._require_role("admin"):
|
||||
return
|
||||
if server.billing is None:
|
||||
self._send_json(404, {"error": "billing is not enabled on this tracker"})
|
||||
return
|
||||
@@ -2413,7 +2483,7 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
|
||||
|
||||
def _handle_billing_gossip(self):
|
||||
server: _TrackerHTTPServer = self.server # type: ignore[assignment]
|
||||
body = self._read_json_body()
|
||||
body = self._read_hive_authenticated_body()
|
||||
if body is None:
|
||||
return
|
||||
if server.billing is None:
|
||||
@@ -2429,12 +2499,11 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
|
||||
def _handle_billing_forfeit(self):
|
||||
"""Privileged: forfeit a node's pending balance + record a strike (US-034).
|
||||
|
||||
Auth is a header-presence stub (non-empty Authorization), matching the
|
||||
benchmark endpoints — real validator auth arrives with on-chain keys.
|
||||
ADR-0017 §4: validator service token or admin session only. Client
|
||||
API keys — valid or not — are rejected.
|
||||
"""
|
||||
server: _TrackerHTTPServer = self.server # type: ignore[assignment]
|
||||
if not self.headers.get("Authorization"):
|
||||
self._send_json(401, {"error": "Authorization header required"})
|
||||
if not self._require_role("validator", "admin"):
|
||||
return
|
||||
if server.billing is None:
|
||||
self._send_json(404, {"error": "billing is not enabled on this tracker"})
|
||||
@@ -2609,7 +2678,7 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
|
||||
|
||||
def _handle_accounts_gossip(self):
|
||||
server: _TrackerHTTPServer = self.server # type: ignore[assignment]
|
||||
body = self._read_json_body()
|
||||
body = self._read_hive_authenticated_body()
|
||||
if body is None:
|
||||
return
|
||||
if server.accounts is None:
|
||||
@@ -2655,10 +2724,9 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
|
||||
is its total minus the (k-1)-node route's total.
|
||||
"""
|
||||
server: _TrackerHTTPServer = self.server # type: ignore[assignment]
|
||||
auth = self.headers.get("Authorization")
|
||||
if not auth:
|
||||
self._send_json(401, {"error": "Authorization header required"})
|
||||
if not self._require_role("admin", "validator"):
|
||||
return
|
||||
auth = self.headers.get("Authorization")
|
||||
body = self._read_json_body()
|
||||
if body is None:
|
||||
return
|
||||
@@ -2744,8 +2812,7 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
|
||||
|
||||
def _handle_benchmark_results(self):
|
||||
server: _TrackerHTTPServer = self.server # type: ignore[assignment]
|
||||
if not self.headers.get("Authorization"):
|
||||
self._send_json(401, {"error": "Authorization header required"})
|
||||
if not self._require_role("admin", "validator"):
|
||||
return
|
||||
results: list = []
|
||||
with server.benchmark_lock:
|
||||
@@ -3198,6 +3265,8 @@ class TrackerServer:
|
||||
payout_threshold: float = 5.0,
|
||||
payout_dust_floor: float = 0.01,
|
||||
settlement_check_interval: float | None = None,
|
||||
validator_service_token: str | None = None,
|
||||
hive_secret: str | None = None,
|
||||
) -> None:
|
||||
self._host = host
|
||||
self._requested_port = port
|
||||
@@ -3256,6 +3325,16 @@ class TrackerServer:
|
||||
)
|
||||
self._settlement_stop = threading.Event()
|
||||
self._settlement_thread: threading.Thread | None = None
|
||||
self._validator_service_token = (
|
||||
validator_service_token
|
||||
if validator_service_token is not None
|
||||
else os.environ.get("MESHNET_VALIDATOR_SERVICE_TOKEN") or None
|
||||
)
|
||||
self._hive_secret = (
|
||||
hive_secret
|
||||
if hive_secret is not None
|
||||
else os.environ.get("MESHNET_HIVE_SECRET") or None
|
||||
)
|
||||
self.port: int | None = None
|
||||
|
||||
def start(self) -> int:
|
||||
@@ -3282,6 +3361,8 @@ class TrackerServer:
|
||||
billing=self._billing,
|
||||
accounts=self._accounts,
|
||||
benchmark_results_path=self._benchmark_results_path,
|
||||
validator_service_token=self._validator_service_token,
|
||||
hive_secret=self._hive_secret,
|
||||
)
|
||||
self.port = self._server.server_address[1]
|
||||
|
||||
@@ -3450,6 +3531,23 @@ class TrackerServer:
|
||||
_purge_expired_nodes_locked(server)
|
||||
_rebalance_all_locked(server)
|
||||
|
||||
def _push_to_peers(self, path: str, body: bytes) -> bool:
|
||||
"""POST a hive-signed payload to every cluster peer; True if all succeeded."""
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if self._hive_secret:
|
||||
headers.update(sign_hive_request(self._hive_secret, body))
|
||||
delivered_all = True
|
||||
for peer in self._cluster_peers:
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
f"{peer}{path}", data=body, headers=headers, method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=2.0) as r:
|
||||
r.read()
|
||||
except Exception:
|
||||
delivered_all = False
|
||||
return delivered_all
|
||||
|
||||
def _stats_loop(self) -> None:
|
||||
"""Periodically save stats/billing to DB and push local slices to cluster peers."""
|
||||
while not self._stats_stop.wait(_StatsCollector.SAVE_INTERVAL):
|
||||
@@ -3459,62 +3557,31 @@ class TrackerServer:
|
||||
self._billing.save_to_db()
|
||||
if self._accounts is not None:
|
||||
self._accounts.save_to_db()
|
||||
if self._cluster_peers and not self._hive_secret:
|
||||
print(
|
||||
"[tracker] WARNING: cluster peers configured without --hive-secret — "
|
||||
"gossip pushes will be rejected (ADR-0017)",
|
||||
flush=True,
|
||||
)
|
||||
if self._stats is not None and self._cluster_peers:
|
||||
self_url = self._cluster_self_url or f"http://{self._host}:{self.port}"
|
||||
local_rpms = self._stats.get_local_rpms()
|
||||
body = json.dumps({"tracker_url": self_url, "stats": local_rpms}).encode()
|
||||
for peer in self._cluster_peers:
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
f"{peer}/v1/stats/gossip",
|
||||
data=body,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=2.0) as r:
|
||||
r.read()
|
||||
except Exception:
|
||||
pass
|
||||
self._push_to_peers("/v1/stats/gossip", body)
|
||||
if self._billing is not None and self._cluster_peers:
|
||||
events, cursor = self._billing.events_since(self._billing_gossip_cursor)
|
||||
if events:
|
||||
delivered_all = True
|
||||
body = json.dumps({"events": events}).encode()
|
||||
for peer in self._cluster_peers:
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
f"{peer}/v1/billing/gossip",
|
||||
data=body,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=2.0) as r:
|
||||
r.read()
|
||||
except Exception:
|
||||
delivered_all = False
|
||||
# Only advance past events every peer has seen; unreachable
|
||||
# peers get the full backlog on the next tick (idempotent
|
||||
# via event-id dedupe on the receiving side).
|
||||
if delivered_all:
|
||||
if self._push_to_peers("/v1/billing/gossip", body):
|
||||
self._billing_gossip_cursor = cursor
|
||||
if self._accounts is not None and self._cluster_peers:
|
||||
events, cursor = self._accounts.events_since(self._accounts_gossip_cursor)
|
||||
if events:
|
||||
delivered_all = True
|
||||
body = json.dumps({"events": events}).encode()
|
||||
for peer in self._cluster_peers:
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
f"{peer}/v1/accounts/gossip",
|
||||
data=body,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=2.0) as r:
|
||||
r.read()
|
||||
except Exception:
|
||||
delivered_all = False
|
||||
if delivered_all:
|
||||
if self._push_to_peers("/v1/accounts/gossip", body):
|
||||
self._accounts_gossip_cursor = cursor
|
||||
|
||||
def stop(self) -> None:
|
||||
|
||||
208
tests/test_auth_boundary.py
Normal file
208
tests/test_auth_boundary.py
Normal file
@@ -0,0 +1,208 @@
|
||||
"""Alpha issues 01/02/20 (ADR-0017): unified auth boundary.
|
||||
|
||||
Forfeit accepts only the validator service token or an admin session; client
|
||||
API keys and garbage bearers are rejected. Financial reads require an admin
|
||||
session. Hive gossip mutations require a fresh HMAC signature — unsigned
|
||||
requests apply nothing.
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
import pytest
|
||||
|
||||
from meshnet_tracker.accounts import AccountStore
|
||||
from meshnet_tracker.auth import sign_hive_request, verify_hive_request
|
||||
from meshnet_tracker.billing import BillingLedger
|
||||
from meshnet_tracker.server import TrackerServer
|
||||
|
||||
MODEL = "stub-model"
|
||||
SERVICE_TOKEN = "svc-validator-secret-token"
|
||||
HIVE_SECRET = "hive-shared-secret"
|
||||
|
||||
|
||||
def _request(url, *, method="GET", payload=None, token=None, extra_headers=None):
|
||||
headers = {"Content-Type": "application/json", **(extra_headers or {})}
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=json.dumps(payload).encode() if payload is not None else None,
|
||||
headers=headers,
|
||||
method=method,
|
||||
)
|
||||
with urllib.request.urlopen(req) as r:
|
||||
return json.loads(r.read())
|
||||
|
||||
|
||||
def _status_of(callable_):
|
||||
try:
|
||||
callable_()
|
||||
except urllib.error.HTTPError as exc:
|
||||
return exc.code
|
||||
return 200
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def secured_tracker():
|
||||
accounts = AccountStore()
|
||||
admin = accounts.register(email="admin@example.com", password="admin-pass-123")
|
||||
user = accounts.register(email="user@example.com", password="user-pass-1234")
|
||||
admin_session = accounts.create_session(admin["account_id"])
|
||||
user_session = accounts.create_session(user["account_id"])
|
||||
client_api_key = accounts.create_api_key(user["account_id"])
|
||||
|
||||
ledger = BillingLedger(starting_credit=1.0, default_price_per_1k=0.02)
|
||||
ledger.charge_request("client", MODEL, 1000, [("wallet-x", 12)])
|
||||
|
||||
tracker = TrackerServer(
|
||||
billing=ledger,
|
||||
accounts=accounts,
|
||||
validator_service_token=SERVICE_TOKEN,
|
||||
hive_secret=HIVE_SECRET,
|
||||
)
|
||||
port = tracker.start()
|
||||
yield {
|
||||
"url": f"http://127.0.0.1:{port}",
|
||||
"ledger": ledger,
|
||||
"admin_session": admin_session,
|
||||
"user_session": user_session,
|
||||
"client_api_key": client_api_key,
|
||||
}
|
||||
tracker.stop()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- forfeit
|
||||
|
||||
|
||||
def test_forfeit_rejects_missing_garbage_and_api_key(secured_tracker):
|
||||
url = f"{secured_tracker['url']}/v1/billing/forfeit"
|
||||
payload = {"wallet": "wallet-x", "reason": "fraud"}
|
||||
|
||||
assert _status_of(lambda: _request(url, method="POST", payload=payload)) == 401
|
||||
assert _status_of(lambda: _request(url, method="POST", payload=payload, token="garbage")) == 403
|
||||
# a *valid* client API key must still be rejected on operator endpoints
|
||||
assert _status_of(lambda: _request(
|
||||
url, method="POST", payload=payload, token=secured_tracker["client_api_key"],
|
||||
)) == 403
|
||||
# nothing was forfeited by any of the rejected attempts
|
||||
assert secured_tracker["ledger"].get_node_pending("wallet-x") > 0
|
||||
|
||||
|
||||
def test_forfeit_accepts_service_token_and_admin_session(secured_tracker):
|
||||
url = f"{secured_tracker['url']}/v1/billing/forfeit"
|
||||
result = _request(
|
||||
url, method="POST", payload={"wallet": "wallet-x"}, token=SERVICE_TOKEN,
|
||||
)
|
||||
assert result["forfeited"] == pytest.approx(0.02 * 0.90)
|
||||
assert secured_tracker["ledger"].get_node_pending("wallet-x") == pytest.approx(0.0)
|
||||
|
||||
# admin session is the operator override (idempotent second call: 0 pending)
|
||||
result = _request(
|
||||
url, method="POST", payload={"wallet": "wallet-x"},
|
||||
token=secured_tracker["admin_session"],
|
||||
)
|
||||
assert result["forfeited"] == pytest.approx(0.0)
|
||||
|
||||
|
||||
# ------------------------------------------------------- financial reads
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", [
|
||||
"/v1/billing/summary",
|
||||
"/v1/billing/settlements",
|
||||
"/v1/registry/wallets",
|
||||
])
|
||||
def test_financial_reads_require_admin_session(secured_tracker, path):
|
||||
url = f"{secured_tracker['url']}{path}"
|
||||
assert _status_of(lambda: _request(url)) == 401
|
||||
assert _status_of(lambda: _request(url, token=secured_tracker["user_session"])) == 403
|
||||
assert _status_of(lambda: _request(url, token=secured_tracker["client_api_key"])) == 403
|
||||
assert _status_of(lambda: _request(url, token=secured_tracker["admin_session"])) == 200
|
||||
|
||||
|
||||
def test_benchmark_endpoints_require_admin_or_service(secured_tracker):
|
||||
url = f"{secured_tracker['url']}/v1/benchmark/results"
|
||||
assert _status_of(lambda: _request(url)) == 401
|
||||
assert _status_of(lambda: _request(url, token=secured_tracker["user_session"])) == 403
|
||||
assert _status_of(lambda: _request(url, token=SERVICE_TOKEN)) == 200
|
||||
assert _status_of(lambda: _request(url, token=secured_tracker["admin_session"])) == 200
|
||||
|
||||
|
||||
def test_dashboard_stays_public(secured_tracker):
|
||||
assert _status_of(lambda: urllib.request.urlopen(
|
||||
f"{secured_tracker['url']}/dashboard"
|
||||
)) == 200
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- gossip
|
||||
|
||||
|
||||
def _gossip_events():
|
||||
peer = BillingLedger(starting_credit=0.0)
|
||||
peer.credit_client("injected-client", 999.0)
|
||||
events, _ = peer.events_since(0)
|
||||
return events
|
||||
|
||||
|
||||
def test_unsigned_gossip_is_rejected_and_applies_nothing(secured_tracker):
|
||||
url = f"{secured_tracker['url']}/v1/billing/gossip"
|
||||
payload = {"events": _gossip_events()}
|
||||
assert _status_of(lambda: _request(url, method="POST", payload=payload)) == 401
|
||||
# a bearer token is not a hive signature either
|
||||
assert _status_of(lambda: _request(
|
||||
url, method="POST", payload=payload, token=secured_tracker["admin_session"],
|
||||
)) == 401
|
||||
assert secured_tracker["ledger"].get_client_balance("injected-client") == 0.0
|
||||
|
||||
|
||||
def test_signed_gossip_applies_and_wrong_secret_rejected(secured_tracker):
|
||||
url = f"{secured_tracker['url']}/v1/billing/gossip"
|
||||
body = json.dumps({"events": _gossip_events()}).encode()
|
||||
|
||||
bad = sign_hive_request("not-the-hive-secret", body)
|
||||
assert _status_of(lambda: _request(
|
||||
url, method="POST", payload=json.loads(body), extra_headers=bad,
|
||||
)) == 401
|
||||
|
||||
good = sign_hive_request(HIVE_SECRET, body)
|
||||
req = urllib.request.Request(
|
||||
url, data=body,
|
||||
headers={"Content-Type": "application/json", **good},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req) as r:
|
||||
assert json.loads(r.read())["applied"] == 1
|
||||
assert secured_tracker["ledger"].get_client_balance("injected-client") == pytest.approx(999.0)
|
||||
|
||||
|
||||
def test_stale_signature_rejected():
|
||||
body = b'{"events": []}'
|
||||
stale = sign_hive_request(HIVE_SECRET, body, timestamp=time.time() - 3600)
|
||||
assert not verify_hive_request(HIVE_SECRET, stale, body)
|
||||
fresh = sign_hive_request(HIVE_SECRET, body)
|
||||
assert verify_hive_request(HIVE_SECRET, fresh, body)
|
||||
# signature must cover the body
|
||||
assert not verify_hive_request(HIVE_SECRET, fresh, b'{"events": [1]}')
|
||||
# fail closed without a configured secret
|
||||
assert not verify_hive_request(None, fresh, body)
|
||||
|
||||
|
||||
def test_accounts_and_stats_gossip_also_gated(secured_tracker):
|
||||
for path in ("/v1/accounts/gossip", "/v1/stats/gossip"):
|
||||
url = f"{secured_tracker['url']}{path}"
|
||||
assert _status_of(lambda: _request(url, method="POST", payload={})) == 401
|
||||
|
||||
|
||||
def test_push_to_peers_signs_so_peers_accept(secured_tracker):
|
||||
"""Outgoing gossip from a tracker with the shared secret lands on a peer."""
|
||||
sender = TrackerServer(
|
||||
billing=BillingLedger(starting_credit=0.0),
|
||||
cluster_peers=[secured_tracker["url"]],
|
||||
hive_secret=HIVE_SECRET,
|
||||
)
|
||||
body = json.dumps({"events": _gossip_events()}).encode()
|
||||
assert sender._push_to_peers("/v1/billing/gossip", body) is True
|
||||
assert secured_tracker["ledger"].get_client_balance("injected-client") == pytest.approx(999.0)
|
||||
Reference in New Issue
Block a user