Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/vm-tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ jobs:
include:
- name: P2P Discovery Test
check: p2p-discovery
- name: Mesh Trust Test
check: mesh-trust
- name: E2E Test
check: e2e
steps:
Expand Down
4 changes: 4 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

60 changes: 50 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,8 +115,12 @@ further in the [architechture document].
- `GET /nar/<path>.nar`: streamed NAR content from the chosen upstream
- `GET /metrics`: Prometheus metrics
- `GET /health`: JSON health summary of configured upstreams
- `GET /trust/<hash>.narinfo`: trust decision and matching claim count
- `GET /provenance/<hash>.narinfo`: stored signer claims for a narinfo hash

### Routing Notes
### Notes

#### Routing

- Route cache decisions are stored in SQLite and reused until their TTL expires
(or they are evicted by the LRU policy when `max_entries` is reached).
Expand All @@ -139,6 +143,24 @@ further in the [architechture document].
part of health probing, discovery, priority routing, filters, cooldown, or
route persistence.

#### Trust

[trust documentation]: ./docs/trust.md

`trust.mode = "signed"` accepts only narinfos that verify against the selected
upstream's configured `public_key`. `trust.mode = "quorum"` records signed
claims and accepts a route only after enough distinct signer keys agree on the
same `StorePath`, `NarHash`, `NarSize`, and `References`. With
`mesh.gossip_trust_claims`, peers relay re-verified claims so a quorum can form
across a mesh where each node sees only one upstream.

> [!NOTE]
> This is output consensus, not full source attestation, and the signature
> covers only the signed fingerprint; not the streamed NAR bytes. See
> [trust documentation] for the complete model: what is and is not verified, how
> a quorum is counted, the `fail_closed` open/closed behavior, and how mesh
> claim relay stays trustworthy.

## Quick Start

```bash
Expand Down Expand Up @@ -214,6 +236,13 @@ per_upstream_max_inflight = 8 # per-upstream narinfo head concurrency
in_memory_negative_ttl = "5s" # short-lived miss suppression
upstream_cooldown = "15s" # cooldown on transient upstream network errors

[trust]
mode = "off" # off | signed | quorum
threshold = 2 # signer agreement needed in quorum mode
require_distinct_signers = true # count signer keys, not upstream URLs
fail_closed = true # reject untrusted candidates when enabled
# claim_ttl = "30d" # optional: ignore claims older than this in quorum

[logging]
level = "info" # debug | info | warn | error
format = "json" # json | text
Expand All @@ -232,6 +261,7 @@ bind_addr = "0.0.0.0:7946"
peers = [] # list of {addr, public_key} peer entries
private_key = "" # path to ed25519 key file; empty = ephemeral
gossip_interval = "30s"
gossip_trust_claims = false # also gossip + re-verify trust claims across peers
```

### Environment Overrides
Expand Down Expand Up @@ -469,6 +499,15 @@ Each peer entry takes an address and an optional ed25519 public key. When a
public key is provided, incoming gossip packets are verified against it; packets
from unlisted senders or with invalid signatures are silently dropped.

> [!TIP]
> Setting `mesh.gossip_trust_claims = true` additionally gossips the trust
> claims this node has verified locally and accepts claims relayed by peers,
> letting a `quorum` policy form across the mesh. A relayed claim only counts if
> its signer key is trusted (a configured upstream key or one listed in
> `trust.trusted_keys`) **and** its narinfo re-verifies against that key, so a
> peer can only relay real signatures from trusted signers, never fabricate a
> quorum with throwaway keys.

If `mesh.private_key` is left empty, ncro generates an ephemeral identity on
startup. That is fine for testing, but persistent gossip requires a stable key
so peers can recognize the node across restarts.
Expand Down Expand Up @@ -501,15 +540,16 @@ Prometheus metrics are available at `/metrics`.

<!--markdownlint-disable MD013-->

| Metric | Type | Description |
| ----------------------------------------- | --------- | ---------------------------------------- |
| `ncro_narinfo_cache_hits_total` | counter | Narinfo requests served from route cache |
| `ncro_narinfo_cache_misses_total` | counter | Narinfo requests requiring upstream race |
| `ncro_narinfo_requests_total{status}` | counter | Narinfo requests by status (200/error) |
| `ncro_nar_requests_total` | counter | NAR streaming requests |
| `ncro_upstream_race_wins_total{upstream}` | counter | Race wins per upstream |
| `ncro_upstream_latency_seconds{upstream}` | histogram | Race latency per upstream |
| `ncro_route_entries` | gauge | Current route entries in SQLite |
| Metric | Type | Description |
| ----------------------------------------- | --------- | ------------------------------------------------------------- |
| `ncro_narinfo_cache_hits_total` | counter | Narinfo requests served from route cache |
| `ncro_narinfo_cache_misses_total` | counter | Narinfo requests requiring upstream race |
| `ncro_narinfo_requests_total{status}` | counter | Narinfo requests by status (200/error) |
| `ncro_nar_requests_total` | counter | NAR streaming requests |
| `ncro_upstream_race_wins_total{upstream}` | counter | Race wins per upstream |
| `ncro_upstream_latency_seconds{upstream}` | histogram | Race latency per upstream |
| `ncro_route_entries` | gauge | Current route entries in SQLite |
| `ncro_trust_bypass_total{reason}` | counter | Content served despite failed trust check (fail_closed=false) |

<!--markdownlint-enable MD013-->

Expand Down
27 changes: 27 additions & 0 deletions config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,27 @@ max_concurrent_races = 64
per_upstream_max_inflight = 8
upstream_cooldown = "15s"

[trust]
# off | signed | quorum. In signed mode, accepted narinfos must verify against
# the selected upstream's public_key. In quorum mode, at least threshold
# distinct configured signer keys must agree on StorePath, NarHash, NarSize,
# and References before ncro stores a route. See docs/trust.md for the full
# model.
fail_closed = true
mode = "off"
require_distinct_signers = true
threshold = 2

# Optional maximum age for a claim to count toward a quorum. Unset = never
# expires. Prevents a long-dead signer from propping up a quorum forever.
# claim_ttl = "30d"
# Signer keys trusted to vouch for content in quorum mode, in addition to the
# public_key of every configured upstream. A quorum counts only claims signed
# by a key in this set; without it an attacker could self-sign forged content
# under throwaway keys and forge agreement. In a mesh, list the other nodes'
# upstream signer keys here so their relayed claims count.
# trusted_keys = ["cache-b-1:base64key=", "cache-c-1:base64key="]

[discovery]
discovery_time = "5s"
domain = "local"
Expand All @@ -65,6 +86,12 @@ gossip_interval = "30s"
peers = [ ]
private_key = "/etc/ncro/node.key"

# When true, this node also gossips locally-verified trust claims to peers and
# accepts claims relayed by peers (each re-verified against its original Nix
# signer key before being trusted). Lets a quorum form across a mesh where each
# node sees only one upstream. Only meaningful with trust.mode = "quorum".
gossip_trust_claims = false

[logging]
format = "json"
level = "info"
Loading
Loading