From 93ab052f04ba3742f4426235cd2b3a9ca7a169d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C4=99drzej=20Stuczy=C5=84ski?= Date: Tue, 7 Jul 2026 09:47:14 +0100 Subject: [PATCH 1/6] initial design and specs --- .../directory-attested-anchor/.openspec.yaml | 2 + .../directory-attested-anchor/design.md | 125 ++++++++++++++++++ .../directory-attested-anchor/proposal.md | 50 +++++++ .../specs/directory-attested-anchor/spec.md | 122 +++++++++++++++++ .../specs/directory-retrieval-client/spec.md | 31 +++++ .../directory-attested-anchor/tasks.md | 68 ++++++++++ 6 files changed, 398 insertions(+) create mode 100644 openspec/changes/directory-attested-anchor/.openspec.yaml create mode 100644 openspec/changes/directory-attested-anchor/design.md create mode 100644 openspec/changes/directory-attested-anchor/proposal.md create mode 100644 openspec/changes/directory-attested-anchor/specs/directory-attested-anchor/spec.md create mode 100644 openspec/changes/directory-attested-anchor/specs/directory-retrieval-client/spec.md create mode 100644 openspec/changes/directory-attested-anchor/tasks.md diff --git a/openspec/changes/directory-attested-anchor/.openspec.yaml b/openspec/changes/directory-attested-anchor/.openspec.yaml new file mode 100644 index 00000000000..dd9a1d92eba --- /dev/null +++ b/openspec/changes/directory-attested-anchor/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-06 diff --git a/openspec/changes/directory-attested-anchor/design.md b/openspec/changes/directory-attested-anchor/design.md new file mode 100644 index 00000000000..14ab560bf17 --- /dev/null +++ b/openspec/changes/directory-attested-anchor/design.md @@ -0,0 +1,125 @@ +## Context + +`DirectoryTrustAnchor` is the seam that produces the block `app_hash` and directory digest the retrieval client is willing to trust at a height. Two implementations exist: `ProvenTrustAnchor` (reads `header[H+1].app_hash` from a configured, trusted RPC) and `LightClientAnchor` (verifies validator-set signatures from a caller-pinned checkpoint). The trust model (`project_directory_contract_trust_model_2026_06_24`) frames the production bootstrap around a hardcoded root key that signs a self-authenticating checkpoint, refreshed out-of-band. That layer (roadmap steps 1b/1c) cannot ship yet: the root key does not exist, and hardcoding one plus an initial checkpoint and a refresh flow is a prerequisite we are deliberately postponing. + +The same trust model describes a second, independent authority that is available today: the curated Tier-1 nym-apis. Their ed25519 identity keys already exist and are already the sanctioned signer tier. `AttestedTrustAnchor` uses a K-of-N quorum of those keys as the trust root. A quorum of nym-apis signs a directory snapshot; the client accepts a snapshot only when at least K distinct trusted signers agree on identical values. This is strictly stronger than trusting a single RPC (an attacker now needs K colluding, independently operated nym-apis) and requires no new key material, so it unblocks a verifiable-retrieval deployment now, behind the same trait the light client will use later. + +Two gaps surfaced once the basic quorum mechanism was sketched, both closed by this design: + +1. **No default trust root.** A caller had to supply `trusted_signers` from nothing - there was no sensible out-of-the-box configuration, which pushes the "who do I trust" decision onto every deployment even when the answer for most of them ("Nym SA's own nym-apis, for now") is the same. +2. **RPC dependency survived the anchor.** Removing the RPC dependency from *establishing* trust (`app_hash`) does not remove it from *using* that trust: `DirectoryClient::verified_directory` still fetches entries and node identities via a live `CosmWasmClient` connection regardless of which anchor is plugged in, and the node-identity lookup in particular was never proven at all (a plain smart query) even under `LightClientAnchor`, which otherwise goes out of its way not to trust the RPC. Some deployments cannot or should not hold a direct chain RPC connection; this design closes that gap for whole-directory retrieval. + +A fourth gap, external to this change but load-bearing for D8's override path: nym-api has no unconditional way to expose its own identity key today (see "External prerequisite" below). + +A third gap - the trusted signer *set itself* being static, requiring a client update whenever Tier-1 membership rotates - was explored in depth (deriving it from the coconut-dkg dealer set and/or directory curated entries, generalizing attestation to arbitrary canonical subsets published by nym-api) but is deliberately left for a follow-up. See "Future direction" below for what was worked out, so it is not lost. + +## Goals / Non-Goals + +**Goals:** + +- Add `AttestedTrustAnchor: DirectoryTrustAnchor` whose trusted `app_hash` and digest come from a K-of-N quorum of configured nym-api identity keys. +- Ship a small, hardcoded, overridable default trust root (Nym-SA-owned nym-api keys/endpoints) so a deployment gets a working configuration with zero setup, while remaining free to substitute its own. +- Define a canonical, replay-resistant, domain-separated snapshot attestation whose signing-payload encoding is shared so a future nym-api producer reproduces identical bytes, and which additionally commits to a hash over the current `NodeId -> ed25519 identity` mapping (not just the directory's own digest). +- Let whole-directory retrieval (entries *and* the node-identity bindings needed for authorship attribution) be verified by local hash-recompute against a trusted snapshot, with data sourced from anywhere - no chain RPC connection required on the verifying side. +- Keep the transport abstract behind an `AttestationSource` trait so the anchor is testable with a mock and the concrete HTTP wiring can land with the producer. +- Support fetching the quorum-agreed latest snapshot and a specific recent height within a retained window, for clients running behind or straddling a key-rotation range transition. +- Leave `ProvenTrustAnchor`, `LightClientAnchor`, and the `DirectoryTrustAnchor` trait surface untouched; their existing (RPC-backed, and in the node-identity case unproven) behavior is preserved exactly. + +**Non-Goals:** + +- The nym-api producer endpoint (separate follow-up; this change fixes the format it must emit). +- A concrete HTTP `AttestationSource` (lands with the producer, defining the wire format once). +- The contract-side refresh-cadence / snapshot-retention parameter (TBD; producer / contract concern). +- The checkpoint / root-key bootstrap (steps 1b/1c), explicitly postponed. +- ICS23-proving the signer keys as curated entries, or deriving the trusted signer set from the coconut-dkg dealer set; in attested mode the signer set is configured (default or override), which is exactly what lets us skip the root-key layer. See "Future direction". +- Generalizing attestation beyond the directory digest and node-identity hash to arbitrary canonical subsets (per-label slices, non-directory nym-api endpoints). See "Future direction". +- Persistent snapshot cache across process restarts. +- nym-api exposing its identity key unconditionally, or a possession-proof/challenge-response endpoint. Named as its own nym-api-side prerequisite; see "External prerequisite" below. + +## Decisions + +### D1: A third anchor, quorum-attested, behind the unchanged trait + +`AttestedTrustAnchor` is a drop-in `DirectoryTrustAnchor` alongside the proven and light-client anchors. It is constructed with the set of trusted nym-api identity keys, a quorum threshold K, the expected chain-id, and the directory contract address - or with the shipped default (see D8). Trust is bootstrapped from those configured keys rather than a root key or a validator-set checkpoint, which is the whole reason it is deployable before steps 1b/1c. Because it satisfies the same trait, the verify core needs no changes; the swap is purely at construction time. + +### D2: Attest the whole snapshot `{height, app_hash, accumulator, node_identities_hash}` + +The attestation carries the block `app_hash`, the directory LtHash `accumulator`, AND a hash over the current `NodeId -> ed25519 identity` mapping sourced from the mixnet contract. All three are computed by the signing nym-api from its own trusted chain access and signed together under one signature, so a client that reaches quorum on this manifest can verify BOTH the directory's contents and the authorship binding for each entry, from data fetched anywhere untrusted, with no chain query of its own. `trusted_app_hash(H)` and `trusted_digest(H)` both read from the SAME quorum-agreed attestation, so they cannot disagree; single-entry reads still ICS23-prove against the attested `app_hash`, and the whole-directory recompute still fails closed on any mismatch (`DigestMismatch`) - a quorum that lied about either hash surfaces as a verification failure, never as silently-accepted bad data. + +The `node_identities_hash` addition specifically is what makes whole-directory retrieval genuinely chain-query-free: without it, `verified_directory` would still need a live, trusted RPC connection to look up node identities (today, that lookup is an *unproven* smart query even under `LightClientAnchor` - a pre-existing gap this does not fix for the other anchors, but does not need to, since they keep their existing RPC-backed behavior unchanged). + +Alternative considered - attest `app_hash` only, then ICS23-derive the accumulator and separately RPC-fetch node identities. Rejected for the same reason as before (weakens the anchor to a generic header oracle, forces it to hold an RPC) and additionally fails to deliver a genuinely RPC-free retrieval path, which is now a stated goal. + +### D3: Canonical signing payload in the shared crate; signed types in the client + +The exact bytes a nym-api signs are produced by `digest_snapshot_signing_payload(chain_id, contract, height, app_hash, accumulator, node_identities_hash) -> Vec` in `nym-directory-contract-common`, next to `node_signing_payload` and reusing its `push_len_prefixed` framing, with a distinct domain-separation tag so a snapshot signature can never be confused with a node-entry signature. The canonical encoder for `node_identities_hash` itself (hashing the sorted `(NodeId, identity)` set) is a separate, small function whose home is a mixnet-contract-common concern (it hashes mixnet bond data, not directory data) rather than living in the directory's shared crate - exact placement is an implementation-time call, not a design fork. This mirrors the existing split: canonical encoding lives in shared crates (single source of truth for producer and consumer), signed-wrapper type and verification logic live in the client (`attested.rs`). + +### D4: Sybil resistance via a configured signer set and a quorum threshold + +The anchor holds `trusted_signers: BTreeSet` and `quorum: usize`, seeded either from the caller's own configuration or from the shipped default (D8). An individual attestation is valid iff its signer is in the trusted set AND its signature verifies over the canonical payload AND its chain-id and contract match the configured ones. A snapshot is trusted iff at least K DISTINCT trusted signers produce valid attestations over identical `(height, app_hash, accumulator, node_identities_hash)`. Distinctness is by signer key, so a single malicious or duplicated source cannot inflate the count. K and N are validated at construction (`1 <= K <= N`). + +### D5: `AttestationSource` transport trait; concrete HTTP deferred + +Unchanged from the original sketch: `#[async_trait] trait AttestationSource { async fn latest_snapshot(&self) -> Result; async fn snapshot_at(&self, height: Height) -> Result; }`. The anchor holds `Vec` and queries them concurrently. The concrete HTTP transport is deferred to the producer change. No new Cargo feature gate needed (only `nym-crypto`, `async-trait`, `serde`). + +### D6: Height model - latest quorum snapshot plus a retained window + +Unchanged: `refresh()` / `latest_snapshot_height()` fetch each source's latest signed snapshot, agree a common value across the quorum, cache it by height in a `BTreeMap` behind a `Mutex`. A miss on a specific height fetches `snapshot_at(H)`; a height the quorum cannot attest returns an error. + +### D7: Freshness by height, not by a baked-in expiry field + +Unchanged: no expiry timestamp in the attestation; staleness is judged by the caller comparing snapshot height against chain tip and a configured maximum lag, keeping the signed payload minimal. + +### D8: A small, hardcoded, overridable default anchor + +`AttestedTrustAnchor` ships a compiled-in default: a small set of Nym-SA-owned nym-api identity keys and endpoints, with a sensible default quorum threshold, exposed as a default-style constructor alongside the fully-configurable `new(sources, trusted_signers, quorum, chain_id, contract)`. This removes the "who do I trust" setup burden for the common case while leaving any operator free to override it entirely (e.g. one who does not trust Nym SA and wants to point at their own nym-api instance as the root of trust). The default is deliberately small and expected to be stable - rotating it requires a crate release, which is an accepted, rare cost (see Risks), not a live operational concern the way Tier-1 membership churn is. + +The override half of this decision is only as usable as an operator's ability to learn their own nym-api's identity key in the first place, which today depends on incidental DKG dealer registration. This change does not solve that; see "External prerequisite" below. + +### D9: Whole-directory verification decoupled from the client's own RPC connection + +The existing `recompute_accumulator` check in `verified_directory` is already source-agnostic (it hashes whatever records it is handed); the only reason the *current* implementation needs an RPC connection is that `DirectoryClient::verified_directory` fetches those records (and node identities) via `self.client`, a bound `CosmWasmClient`. This change extracts the verification logic (digest recompute + node-identity-hash recompute + per-entry signature attribution) into a form that accepts pre-fetched data and needs only the anchor's trusted snapshot - no `CosmWasmClient` bound at all. `DirectoryClient::verified_directory` becomes a thin wrapper that fetches via `self.client` as before and calls into that shared logic, so existing RPC-backed callers (including `ProvenTrustAnchor` / `LightClientAnchor` users) see no behavior change. A caller using `AttestedTrustAnchor` who sourced entries and node identities from elsewhere (any nym-api, a mirror, a CDN) can call the decoupled path directly, with no `CosmWasmClient` in the picture. This path is only available when the anchor's trusted snapshot actually carries a `node_identities_hash` (today, only `AttestedTrustAnchor`); calling it against an anchor that does not returns a clear error rather than silently skipping authorship verification. + +## Risks / Trade-offs + +- [Quorum collusion] K colluding, trusted signers can attest a false snapshot. Mitigation: choose independently operated nym-apis and set K high relative to N; the whole-directory recompute still fails closed if the attested hashes do not match the served data. Residual: a fully colluding quorum could serve a self-consistent fake directory *and* fake identity bindings. This is the explicit trust assumption of attested mode - strictly weaker than the light client, strictly stronger than a single trusted RPC. +- [Liveness at cadence boundaries] If fewer than K sources are reachable, or the quorum straddles a snapshot boundary and disagrees on the latest height, `refresh()` fails. Mitigation: the retained window lets the client fall back to a slightly older agreed height; `QuorumNotReached { needed, agreed }` surfaces the shortfall clearly. +- [Default-anchor staleness] The shipped default is compiled in; if Nym SA ever needs to rotate those specific keys, every deployment relying on the default needs a new crate release to pick up the change. Mitigation: keep the default small (fewer keys to ever need rotating) and document the override path prominently so operators with stricter needs are not depending on it in the first place. This is the same staleness shape as the light-client checkpoint's trust period, just far rarer and smaller in blast radius. +- [Signer-set churn] Rotating a nym-api key (beyond the default) requires reconfiguring clients. Mitigation: the set is config-supplied, not hardcoded, for callers who override it; a future upgrade (see "Future direction") can derive the live set from chain state instead. +- [Format churn when the producer lands] Mitigation: the canonical signing payload is defined now in the shared crate, and the producer follow-up reuses it verbatim. + +## External prerequisite (nym-api-side, tracked separately) + +D8's override path - a caller substituting their own nym-api(s) as the trust root instead of the shipped default - only works in practice if that nym-api can be identified. Today, an ed25519 identity key is only discoverable via the coconut-dkg contract's dealer registry (`DealerDetails.ed25519_identity`), which requires the nym-api to be a currently-registered DKG dealer - incidental to, and not required by, operating a directory-attestation-capable nym-api. An operator running a nym-api purely for this purpose has no API-exposed way to learn (or have others learn) that instance's identity key at all. + +This is named here as its own follow-up, distinct from the nym-api producer endpoint (which signs snapshots; this is about identifying who can be asked to). Two things it should cover, with protocol details deferred to that follow-up rather than worked out here: + +- Unconditional identity-key exposure: a plain endpoint returning the instance's own identity key, independent of DKG participation. +- Proof of live possession: a challenge-response (a nonce signed with the identity key) so a caller wiring up an override anchor can confirm whoever answers at a given URL currently holds the claimed key, rather than trusting an unauthenticated claim. Not required for the quorum mechanism's own soundness (a spoofed source just fails signature verification harmlessly) - this is about setup-time / discovery-time confidence, not core protocol security. + +This change does not implement either; it only flags the dependency so D8's override path is understood to need it for broad usability beyond the shipped default. + +## Migration Plan + +1. Add `digest_snapshot_signing_payload` (now including `node_identities_hash`) to `nym-directory-contract-common`, and the node-identity-mapping canonical hash encoder to its chosen home, both with unit tests mirroring the `node_signing_payload` determinism / field-sensitivity tests. +2. Implement `SignedDigestSnapshot`, the `AttestationSource` trait, `AttestedTrustAnchor`, and the default anchor constants in `src/anchor/attested.rs`. +3. Re-export the public types from `src/anchor/mod.rs`. +4. Add the new error variants to `DirectoryClientError`. +5. Extract the decoupled, data-source-agnostic verification path in `client.rs` / `verify.rs`; make `DirectoryClient::verified_directory` a thin wrapper over it so existing RPC-backed behavior for all three anchors is unchanged. +6. Document in the crate README when to use each anchor and each retrieval path: `ProvenTrustAnchor` (local-dev / tests), `AttestedTrustAnchor` (production before a root key exists, with or without a live chain connection), `LightClientAnchor` (production with a checkpoint). + +## Open Questions + +- Refresh cadence: a fixed client-side interval, or a contract-configured parameter so all instances agree on snapshot heights? (User TBD - leaning contract-side for consistency.) +- Retained-window size: how many previous snapshots should producers keep? Tied to the sphinx-key rotation range width and the maximum expected client lag. +- Freshness: is height-vs-chain-tip lag sufficient, or does the attestation eventually need an `issued_at` / expiry field? +- Default anchor size/composition: exactly how many Nym-SA keys, and what quorum threshold, ships as the default? (Implementation-time call, not blocking design.) +- Node-identity-hash encoder home: `nym-mixnet-contract-common` or elsewhere - implementation-time call. + +## Future direction (deferred, not in scope for this change) + +Explored at length and deliberately parked as a follow-up rather than folded in here, so the thinking isn't lost: + +- **Generalized canonical-subset attestation.** Instead of attesting only the whole-directory digest and node-identity hash, a nym-api could sign a manifest `{height, app_hash, hashes: {subset -> hash}}` covering *any* canonical subset it knows how to compute - per-`KnownLabel` slices (e.g. "sphinx keys of all nodes"), and eventually subsets unrelated to the directory contract entirely. The subset identifier would naturally be implicit in nym-api's own versioned HTTP endpoints (e.g. `v4/nym-nodes/basic`), each with its own well-defined canonical encoding that all nym-apis compute identically - no shared enum to keep in sync, extensibility follows the API's existing versioning convention. +- **Dynamic Tier-1 signer-set discovery**, motivated by not wanting to hand-maintain a rotating key list: a `Tier1SignerSet` canonical subset (the union of the coconut-dkg contract's current dealer set and the directory contract's curated entries) attested the same way as any other subset. A small, stable anchor (D8) bootstraps trust in one snapshot; the live Tier-1 set is then derived from that snapshot each refresh, rather than hand-maintained. Two sourcing asymmetries were identified: curated entries are covered by the directory's own accumulator and support full verifiable enumeration; coconut-dkg dealers (`EPOCH_DEALERS_MAP`, epoch-scoped, no accumulator) only support per-candidate existence proofs, not enumeration - which turns out to be fine, since soundness (everyone admitted is real) is what quorum trust needs, not completeness (finding every last one). +- **Open scope question for that follow-up**: whether the general signed-manifest + quorum-verification core belongs inside `nym-directory-client` (a directory-specific consumer of a small, fixed set of subsets) or in a more general home (e.g. near `nym-api-requests`) if a real second, non-directory consumer materializes first. diff --git a/openspec/changes/directory-attested-anchor/proposal.md b/openspec/changes/directory-attested-anchor/proposal.md new file mode 100644 index 00000000000..919a60218c4 --- /dev/null +++ b/openspec/changes/directory-attested-anchor/proposal.md @@ -0,0 +1,50 @@ +## Why + +The directory retrieval client can already anchor trust two ways: `ProvenTrustAnchor` (trusts a configured RPC for `app_hash`) and `LightClientAnchor` (verifies validator-set signatures, but needs a caller-supplied checkpoint). The remaining gap in the trust model (`project_directory_contract_trust_model_2026_06_24`) is a deployable bootstrap. The planned checkpoint / root-key layer (steps 1b/1c) is blocked: it requires generating and hardcoding a root key that does not exist yet, plus an initial checkpoint and a weak-subjectivity refresh flow. + +The trust model already describes a second, independent authority we can stand up today: a K-of-N quorum of curated nym-api (Tier-1) identity keys, which already exist. `AttestedTrustAnchor` implements the `DirectoryTrustAnchor` seam using that quorum. Instead of a light client vouching for `app_hash` via validator signatures, a configurable quorum of nym-apis signs a directory snapshot, and the client accepts it only when K distinct trusted signers agree on identical values. This unblocks a verifiable-retrieval deployment without minting a root key, and slots behind the same trait so the verify core is untouched. + +Two further requirements shaped this beyond the original sketch: + +- Deployments should not have to hand-maintain a list of trusted nym-api keys with no sensible default. A small, stable, overridable default anchor removes that burden for the common case (trusting Nym SA's own nym-apis) while leaving the door open for an operator who does not want that trust. That override path has a real-world dependency this change does not solve: nym-api does not yet expose its own identity key unconditionally (see Non-Goals). +- Some deployments cannot or do not want to hold a direct chain RPC connection at all. The original sketch only removed the RPC dependency from *establishing* trust (`app_hash`); the directory data itself, and the node-identity bindings needed to attribute entries to their authors, still required a direct, trusted RPC connection regardless of anchor. Closing that gap for whole-directory retrieval is the other half of this change. + +A third direction - generalizing attestation from "the whole directory" to *any* canonical subset a nym-api can publish (per-label slices, the current Tier-1 signer set itself derived from on-chain state, and eventually non-directory nym-api endpoints) - was explored alongside this but is explicitly out of scope here; see Non-Goals and `design.md`'s "Future direction". + +## What Changes + +- Add `AttestedTrustAnchor` in `common/nym-directory-client/src/anchor/attested.rs`, a third `DirectoryTrustAnchor` implementation backed by a K-of-N quorum of configured nym-api identity keys. +- Define the signed snapshot attestation: the canonical signing-payload encoding lives in `nym-directory-contract-common` (next to `node_signing_payload`); the signed wrapper type and quorum verification live in the client crate. The snapshot commits to `{height, app_hash, accumulator, node_identities_hash}` - the directory digest AND a hash over the current `NodeId -> ed25519 identity` bindings from the mixnet contract, both computed by the signing nym-api from its own trusted chain access. +- Ship a small, hardcoded default anchor set (Nym-SA-owned nym-api identity keys/endpoints) as the out-of-the-box `trusted_signers`/sources for `AttestedTrustAnchor`, overridable by any caller who wants a different trust root. +- Decouple whole-directory verification from the client's own RPC connection: given a trusted snapshot, directory entries and node identities fetched from *any* untrusted source can be verified by local hash-recompute (`recompute_accumulator`, already existing, plus a new equivalent for node identities) with no chain query at all. The existing RPC-backed convenience path on `DirectoryClient` is preserved unchanged for callers that do hold a chain connection. +- Add an `AttestationSource` transport trait (fetch latest snapshot / fetch snapshot at a height) so the anchor is transport-agnostic and testable with a mock; the concrete HTTP transport and the nym-api producer endpoint are deferred to a follow-up. +- Height model: the anchor discovers the quorum-agreed LATEST snapshot and can also verify a specific recent height within the producers' retained window (for clients running behind, or straddling a sphinx-key rotation range transition). +- `ProvenTrustAnchor` and `LightClientAnchor` are unchanged; the `DirectoryTrustAnchor` trait surface is unchanged. Their node-identity lookups keep using the existing (unproven) smart query via the caller's RPC connection. +- Postpone the checkpoint / root-key bootstrap layer (steps 1b/1c) until a root key exists. + +## Capabilities + +### New Capabilities + +- `directory-attested-anchor`: A `DirectoryTrustAnchor` implementation that establishes the trusted `app_hash`, directory digest, and node-identity binding from a K-of-N quorum of nym-api (Tier-1) identity keys signing a snapshot, ships with a small overridable default trust root, and requires no root key, no light-client checkpoint, and (for whole-directory retrieval) no direct chain RPC connection. + +### Modified Capabilities + +- `directory-retrieval-client`: gains `AttestedTrustAnchor` as a third anchor behind the same `DirectoryTrustAnchor` trait, and gains a way to verify a whole-directory fetch sourced from outside the client's own chain connection (single-entry ICS23 retrieval is unchanged). + +## Impact + +- `common/nym-directory-client/`: new `src/anchor/attested.rs` (anchor + attestation types + `AttestationSource` trait + default anchor constants), updated `src/anchor/mod.rs` (re-exports), new error variants in `src/error.rs`, and a new data-source-agnostic verification entry point in `src/client.rs` (existing RPC-backed methods become thin wrappers around it). No new runtime deps (reuses `nym-crypto` ed25519 + `async-trait` + `serde`), so no feature gate. +- `common/cosmwasm-smart-contracts/directory-contract/` (`nym-directory-contract-common`): new `digest_snapshot_signing_payload` canonical encoder next to `node_signing_payload`, extended to bind `node_identities_hash` alongside `app_hash`/`accumulator`, so a future producer reproduces identical bytes. +- A new canonical encoder for the `NodeId -> ed25519 identity` mapping hash (home TBD at implementation time - likely `nym-mixnet-contract-common`, next to the bond types it hashes). +- `verify.rs` / `client.rs` gain the decoupled verification path described above; `proof.rs`, `key.rs`, the contract logic, and `nym-api` are otherwise untouched (the producer endpoint is a follow-up). +- Consumers that want attested mode construct `AttestedTrustAnchor::new(sources, trusted_signers, quorum, chain_id, contract)` (or the default-anchor constructor), call `latest_snapshot_height()`, then drive `verified_directory(height)` as before, or the new no-RPC verification entry point if they sourced the data themselves. + +## Non-Goals + +- The nym-api producer endpoint that computes and signs snapshots (separate follow-up; this change defines the shared attestation format it must emit). +- A concrete HTTP `AttestationSource` implementation (lands with the producer, so the wire format is defined once, end to end). +- The contract-side directory refresh-cadence / snapshot-retention parameter (TBD; a producer / contract concern). +- The checkpoint / root-key bootstrap (steps 1b/1c), explicitly postponed until a root key exists. +- **nym-api identity-key exposure and possession-proof.** A prerequisite for D8's override path to be usable by operators who are not coconut-dkg dealers: today a nym-api's ed25519 identity key is only discoverable incidentally via DKG dealer registration (`DealerDetails.ed25519_identity`), so an operator running a nym-api purely to serve directory attestations has no API-exposed way to have their instance's identity key learned at all. Named here as its own nym-api-side follow-up, distinct from the producer endpoint above (that one signs snapshots; this one establishes who can be asked to). Protocol details - including a challenge-response mechanism so a caller can confirm live possession of the claimed key, not just an unauthenticated claim - are deferred to that follow-up. +- **Generalized canonical-subset attestation** (per-label subsets, a `Tier1SignerSet` subset derived from coconut-dkg dealers and curated entries, and eventually non-directory nym-api endpoints): explored at length during design but deferred to a named follow-up change. See `design.md`'s "Future direction" section for what was worked out, so it is not lost. diff --git a/openspec/changes/directory-attested-anchor/specs/directory-attested-anchor/spec.md b/openspec/changes/directory-attested-anchor/specs/directory-attested-anchor/spec.md new file mode 100644 index 00000000000..43f23c43cfc --- /dev/null +++ b/openspec/changes/directory-attested-anchor/specs/directory-attested-anchor/spec.md @@ -0,0 +1,122 @@ +# directory-attested-anchor Specification + +## Purpose + +Defines the requirements for `AttestedTrustAnchor`, a `DirectoryTrustAnchor` implementation that establishes the trusted block `app_hash`, directory digest, and node-identity binding from a K-of-N quorum of configured nym-api (Tier-1) identity keys. It replaces both the honest-RPC assumption of `ProvenTrustAnchor` and the checkpoint requirement of `LightClientAnchor` with a different trust root: a snapshot is accepted only when at least K distinct configured signers sign identical `(height, app_hash, accumulator, node_identities_hash)` values. It ships with a small, overridable default trust root, requires no root key and no light-client checkpoint, and lets whole-directory retrieval proceed with no direct chain RPC connection at all. + +## Requirements + +### Requirement: Quorum trust root configured out-of-band, with a default +`AttestedTrustAnchor` SHALL be constructed with a set of trusted nym-api ed25519 identity keys, a quorum threshold `K`, the expected chain-id, and the directory contract address. It SHALL ship with a small, hardcoded default trust root (Nym-SA-owned identity keys and a default `K`) usable with no caller-supplied configuration, and SHALL allow any caller to override that default with its own signer set and threshold. It SHALL NOT require a root key or a light-client checkpoint, and SHALL NOT fetch or self-bootstrap its trusted signer set from any untrusted source. Construction SHALL reject a configuration where `K` is zero or `K` exceeds the number of trusted signers. + +#### Scenario: Valid configuration initialises the anchor +- **WHEN** a caller constructs `AttestedTrustAnchor::new(sources, trusted_signers, quorum, chain_id, contract)` with `1 <= quorum <= trusted_signers.len()` +- **THEN** the anchor stores the signer set, threshold, chain-id, and contract, and makes no network call during construction + +#### Scenario: Default anchor requires no caller-supplied signer set +- **WHEN** a caller constructs the anchor via its default-anchor constructor, supplying only sources, chain-id, and contract +- **THEN** the anchor uses the compiled-in default `trusted_signers` and quorum threshold + +#### Scenario: A caller can override the default trust root +- **WHEN** a caller constructs `AttestedTrustAnchor::new(...)` with its own `trusted_signers` and `quorum`, distinct from the compiled-in default +- **THEN** the anchor uses only the caller-supplied set, and the default is not consulted + +#### Scenario: Degenerate quorum is rejected +- **WHEN** `new` is called with `quorum == 0` or `quorum > trusted_signers.len()` +- **THEN** it returns an `InvalidQuorumConfig` error and no anchor is constructed + +### Requirement: Canonical, replay-resistant attestation format +The bytes a nym-api signs SHALL be produced by a shared canonical encoder (`digest_snapshot_signing_payload`) that binds a domain-separation tag, the chain-id, the contract address, the height, the `app_hash`, the digest `accumulator`, and a hash over the current `NodeId -> ed25519 identity` mapping (`node_identities_hash`), using length-prefixed framing so adjacent variable-length fields cannot be confused. The domain tag SHALL differ from the node-entry signing payload so a snapshot signature can never be interpreted as a node-entry signature. The producer and the client SHALL use the identical encoder for both the signing payload and the `node_identities_hash` itself. + +#### Scenario: Payload is deterministic and field-sensitive +- **WHEN** the encoder is called twice with the same inputs +- **THEN** it returns identical bytes, and any change to chain-id, contract, height, app_hash, accumulator, or node_identities_hash produces different bytes + +#### Scenario: Node-identity hash is deterministic and order-independent +- **WHEN** the node-identity hash encoder is called twice with the same `(NodeId, identity)` pairs presented in different iteration order +- **THEN** it returns identical bytes, and any change to the set of pairs produces a different hash + +#### Scenario: Cross-chain or cross-contract replay is rejected +- **WHEN** a validly signed snapshot carries a chain-id or contract address other than the ones the anchor was configured with +- **THEN** the anchor treats that attestation as invalid and does not count it toward quorum + +### Requirement: Quorum verification of a snapshot +`trusted_app_hash(H)` and `trusted_digest(H)` SHALL accept a snapshot only when at least `K` DISTINCT trusted signers produce valid signatures over identical `(height, app_hash, accumulator, node_identities_hash)` values. An attestation is valid only if its signer is in the configured trusted set, its chain-id and contract match, and its ed25519 signature verifies over the canonical payload. Distinctness SHALL be by signer key, so a repeated signer counts once. If no set of values reaches `K` distinct valid signers, the anchor SHALL return a `QuorumNotReached` error and MUST NOT return an `app_hash`, digest, or node-identities hash. + +#### Scenario: K agreeing distinct signers are accepted +- **WHEN** at least `K` distinct trusted signers return valid attestations over the same `(height, app_hash, accumulator, node_identities_hash)` +- **THEN** the anchor accepts that snapshot and caches it + +#### Scenario: Fewer than K agreeing signers are rejected +- **WHEN** fewer than `K` distinct trusted signers agree on the same values +- **THEN** the anchor returns `QuorumNotReached { needed, agreed }` and returns no trusted value + +#### Scenario: A repeated signer counts once +- **WHEN** the same trusted signer's attestation is presented multiple times +- **THEN** it contributes at most one toward the quorum count + +#### Scenario: Untrusted or invalid attestations are ignored +- **WHEN** an attestation is signed by a key not in the trusted set, carries an invalid signature, or binds a mismatched chain-id or contract +- **THEN** it is excluded from the quorum count rather than causing the whole verification to error + +#### Scenario: Disagreeing signers do not form a quorum +- **WHEN** trusted signers return valid attestations but split across different `(app_hash, accumulator, node_identities_hash)` values so that no single value reaches `K` +- **THEN** the anchor returns `QuorumNotReached` and returns no trusted value + +### Requirement: app_hash, digest, and node-identity hash from the same attestation +`trusted_app_hash(H)`, `trusted_digest(H)`, and the node-identities hash accessor SHALL return values drawn from the SAME quorum-agreed attestation for `H`, so they cannot disagree. `trusted_digest(H)` SHALL return the attested accumulator directly and SHALL NOT require an ICS23 proof, because the quorum attests the digest itself. The node-identities hash SHALL likewise be returned without any additional proof. + +#### Scenario: Digest is returned without an ICS23 proof +- **WHEN** a quorum-agreed snapshot for `H` exists +- **THEN** `trusted_digest(H)` returns its `accumulator` without performing any store-membership proof + +#### Scenario: app_hash, digest, and node-identity hash are drawn from one snapshot +- **WHEN** `trusted_app_hash(H)`, `trusted_digest(H)`, and the node-identities hash accessor are all called for the same `H` +- **THEN** all three are served from the one cached snapshot for `H`, so they cannot disagree + +### Requirement: Latest snapshot discovery and retained-window heights +The anchor SHALL support discovering the quorum-agreed LATEST snapshot (`refresh` / `latest_snapshot_height`) by querying each source's latest attestation and reaching quorum. For a specific height `H`, `trusted_app_hash(H)` / `trusted_digest(H)` SHALL serve a cached snapshot for `H` if present, and otherwise fetch a per-height attestation from the quorum. A height the quorum cannot attest (outside the producers' retained window) SHALL return an error rather than any unverified value. + +#### Scenario: Latest agreed snapshot is discovered and pinned +- **WHEN** `latest_snapshot_height()` (or `refresh()`) is called and a quorum agrees on a latest snapshot +- **THEN** the anchor caches it and returns its height, which the caller uses to drive `verified_directory` + +#### Scenario: A recent past height within the window is verified +- **WHEN** `trusted_app_hash(H)` is called for a height `H` older than the latest but still served by the quorum +- **THEN** the anchor fetches and quorum-verifies the snapshot at `H` and returns its `app_hash` + +#### Scenario: A height outside the retained window is rejected +- **WHEN** `trusted_app_hash(H)` is called for a height no quorum snapshot exists for +- **THEN** the anchor returns `NoQuorumSnapshotForHeight(H)` and no value + +### Requirement: In-memory cache of quorum-agreed snapshots +Quorum-agreed snapshots SHALL be cached in memory within the anchor instance, keyed by height. Repeated calls for the same height within one process lifetime SHALL be served from cache without re-querying the sources. + +#### Scenario: Repeated query uses the cache +- **WHEN** `trusted_app_hash(H)` is called twice for the same `H` in one session +- **THEN** the sources are queried only once; the second call returns from cache + +### Requirement: Transport abstraction +Attestations SHALL be fetched through an `AttestationSource` abstraction (fetch latest / fetch at a height), so the anchor is independent of any particular transport and can be exercised with a mock source. The concrete HTTP transport and the nym-api producer endpoint are out of scope for this capability. + +#### Scenario: Anchor operates against any AttestationSource +- **WHEN** the anchor is constructed with sources implementing `AttestationSource` +- **THEN** all attestation fetching goes through that trait, and a test mock can drive every path + +### Requirement: Available in the default build +`AttestedTrustAnchor` SHALL compile in the default `nym-directory-client` build without any feature flag (it introduces no heavy dependency), and SHALL also compile when the `light-client` feature is enabled. + +#### Scenario: Present without any feature +- **WHEN** `nym-directory-client` is compiled with no extra features +- **THEN** `AttestedTrustAnchor` is available + +### Requirement: Whole-directory verification requires no chain RPC connection +Given a quorum-agreed snapshot for height `H`, directory entries and the `NodeId -> ed25519 identity` mapping fetched from ANY source SHALL be verifiable by local hash recomputation alone (against the attested `accumulator` and `node_identities_hash` respectively), with no chain RPC connection required by the verifying party. This SHALL fail closed: a mismatch in either recomputed hash SHALL be treated as a verification failure, not partial success. + +#### Scenario: Matching data verifies without any chain connection +- **WHEN** directory entries and a node-identity mapping obtained from any untrusted source are checked against an `AttestedTrustAnchor`'s trusted snapshot for `H` +- **THEN** both are accepted once their locally recomputed hashes equal the attested `accumulator` and `node_identities_hash`, with no RPC call made + +#### Scenario: A mismatch in either hash fails closed +- **WHEN** the recomputed accumulator or the recomputed node-identities hash does not match the attested value +- **THEN** verification fails and no directory data is returned, regardless of which of the two hashes disagreed diff --git a/openspec/changes/directory-attested-anchor/specs/directory-retrieval-client/spec.md b/openspec/changes/directory-attested-anchor/specs/directory-retrieval-client/spec.md new file mode 100644 index 00000000000..a6da2a44691 --- /dev/null +++ b/openspec/changes/directory-attested-anchor/specs/directory-retrieval-client/spec.md @@ -0,0 +1,31 @@ +## ADDED Requirements + +### Requirement: Attested anchor for keyless bootstrap +The crate SHALL provide `AttestedTrustAnchor` as a `DirectoryTrustAnchor` implementation that establishes the trusted `app_hash`, directory digest, and node-identity binding from a K-of-N quorum of configured nym-api identity keys, requiring no root key and no light-client checkpoint. It SHALL ship with a small, overridable default trust root. Deployments that cannot yet provision a light-client checkpoint MAY use it; `ProvenTrustAnchor` and `LightClientAnchor` remain available and unchanged. + +#### Scenario: AttestedTrustAnchor satisfies DirectoryTrustAnchor +- **WHEN** `DirectoryClient` is constructed with an `AttestedTrustAnchor` +- **THEN** `verified_directory` and `verified_node_entry` / `verified_curated_entry` behave identically to the other anchors, with the sole difference that `trusted_app_hash` and `trusted_digest` are sourced from a signed-snapshot quorum instead of an RPC header or a light-client verification + +#### Scenario: Whole-directory recompute still guards the attested digest +- **WHEN** `verified_directory(H)` runs against an `AttestedTrustAnchor` and the locally recomputed accumulator over the fetched entries does not equal the quorum-attested accumulator +- **THEN** the client returns a `DigestMismatch` error and no entries, so a false attested digest fails closed rather than being accepted + +#### Scenario: Single-entry reads remain ICS23-proven +- **WHEN** `verified_node_entry` or `verified_curated_entry` is called against an `AttestedTrustAnchor` +- **THEN** the entry is still verified by an ICS23 membership proof against the quorum-attested `app_hash`, preserving the trustless per-entry path + +### Requirement: Whole-directory retrieval without a chain RPC connection +When the configured anchor's trusted snapshot carries a node-identities hash (today, only `AttestedTrustAnchor`), the crate SHALL provide a way to verify a whole-directory fetch - entries and node identities alike - using only locally recomputed hashes, with no `CosmWasmClient` / chain RPC connection required. The existing RPC-backed `DirectoryClient::verified_directory` path SHALL remain available and behaviorally unchanged for all anchors. + +#### Scenario: Directory verified from data sourced without any chain connection +- **WHEN** a caller supplies directory entries and a node-identity mapping obtained from any source (not a chain RPC connection) alongside an `AttestedTrustAnchor`'s trusted snapshot for height `H` +- **THEN** the crate verifies both the entries (against the accumulator) and the node-identity mapping (against the node-identities hash) by local recompute alone, and returns the same `VerifiedDirectory` shape as the RPC-backed path + +#### Scenario: Decoupled verification fails closed without a node-identities hash +- **WHEN** the decoupled verification path is used with an anchor whose trusted snapshot does not carry a node-identities hash (e.g. `ProvenTrustAnchor`, `LightClientAnchor`) +- **THEN** it returns an error rather than skipping authorship verification silently + +#### Scenario: Existing RPC-backed retrieval is unaffected +- **WHEN** `DirectoryClient::verified_directory` is called as before, with any anchor +- **THEN** it fetches entries and node identities via the client's own chain connection exactly as it did previously, with no observable behavior change diff --git a/openspec/changes/directory-attested-anchor/tasks.md b/openspec/changes/directory-attested-anchor/tasks.md new file mode 100644 index 00000000000..2d044c3cdc6 --- /dev/null +++ b/openspec/changes/directory-attested-anchor/tasks.md @@ -0,0 +1,68 @@ +## 1. Canonical attestation encoding (shared crates) + +- [ ] 1.1 Add `digest_snapshot_signing_payload(chain_id: &str, contract: &AccountId, height: u64, app_hash: &[u8], accumulator: &[u8; DIGEST_LEN], node_identities_hash: &[u8; 32]) -> Vec` to `common/cosmwasm-smart-contracts/directory-contract/src/helpers.rs`, reusing `push_len_prefixed` and prefixing a distinct domain-separation tag (so a snapshot signature can never collide with a `node_signing_payload` signature) +- [ ] 1.2 Re-export it from `nym-directory-contract-common`'s public surface +- [ ] 1.3 Unit tests: payload is deterministic and field-sensitive (differs on any of chain-id / contract / height / app_hash / accumulator / node_identities_hash); length-prefix framing disambiguates adjacent variable-length fields; domain tag differs from the node-entry payload +- [ ] 1.4 Add a canonical hash encoder for the `NodeId -> ed25519 identity` mapping (sorted, length-prefixed pairs, plain cryptographic hash - not an LtHash accumulator, since it is recomputed fresh each time rather than incrementally updated) in an appropriate shared crate (e.g. `nym-mixnet-contract-common`, next to the bond types it hashes) +- [ ] 1.5 Unit tests for the node-identity hash encoder: deterministic, sensitive to any `(node_id, identity)` change, order-independent (sorts internally so caller iteration order does not matter) + +## 2. Attestation types and transport trait (client crate) + +- [ ] 2.1 Define `DigestSnapshot { chain_id: String, directory_contract: AccountId (or String), height: Height, app_hash: AppHash, accumulator: [u8; DIGEST_LEN], node_identities_hash: [u8; 32] }` and `SignedDigestSnapshot { snapshot: DigestSnapshot, signer: ed25519::PublicKey, signature: Vec }` in `src/anchor/attested.rs` (Serialize/Deserialize; signature kept as bytes so malformed data is a verification failure, not a decode panic - mirrors `DirectoryNodeEntry`) +- [ ] 2.2 Implement `SignedDigestSnapshot::verify(&self, trusted: &BTreeSet, chain_id: &str, contract: &AccountId) -> bool`: signer in trusted set AND chain-id + contract match AND ed25519 signature verifies over `digest_snapshot_signing_payload(..)` (mirror `node_signature_verifies`) +- [ ] 2.3 Define `#[async_trait] pub trait AttestationSource { async fn latest_snapshot(&self) -> Result; async fn snapshot_at(&self, height: Height) -> Result; }` + +## 3. AttestedTrustAnchor core + +- [ ] 3.1 Define `TrustedSnapshot { app_hash: AppHash, accumulator: LtHash16, node_identities_hash: [u8; 32] }` and private `AttestedTrustAnchorState { snapshots: BTreeMap, latest: Option }` +- [ ] 3.2 Define `AttestedTrustAnchor { sources: Vec, trusted_signers: BTreeSet, quorum: usize, chain_id: String, directory_contract: AccountId, state: Mutex }` +- [ ] 3.3 Implement `new(sources, trusted_signers, quorum, chain_id, directory_contract)` validating `1 <= quorum <= trusted_signers.len()` (error otherwise) +- [ ] 3.4 Implement a private `reach_quorum(candidates: Vec) -> Result<(Height, TrustedSnapshot), DirectoryClientError>`: filter to valid attestations (via `verify`), group by `(height, app_hash, accumulator, node_identities_hash)`, count DISTINCT signer keys per group, accept the first group reaching `quorum`, else `QuorumNotReached { needed, agreed }` +- [ ] 3.5 Implement `refresh(&self) -> Result`: query all sources' `latest_snapshot()` concurrently, `reach_quorum`, insert into `snapshots`, set `latest`, return the agreed height +- [ ] 3.6 Implement `latest_snapshot_height(&self) -> Result`: return cached `latest` or call `refresh()` +- [ ] 3.7 Implement a private `snapshot_for(&self, height) -> Result`: cache hit on `height` returns immediately; on miss query all sources' `snapshot_at(height)`, `reach_quorum` (verifying the returned height matches), cache, return; a height the quorum cannot attest returns `NoQuorumSnapshotForHeight(height)` + +## 4. Default anchor + +- [ ] 4.1 Define compiled-in default anchor constants (Nym-SA-owned nym-api identity keys + a default quorum threshold; concrete key material and endpoints TBD at implementation time) +- [ ] 4.2 Implement a default-anchor constructor (e.g. `AttestedTrustAnchor::with_default_anchor(sources, chain_id, directory_contract)`) that builds the anchor with the default `trusted_signers`/quorum, alongside the fully-configurable `new(...)` for callers who want to override +- [ ] 4.3 Unit test: the default-anchor constructor produces an anchor whose `trusted_signers`/quorum match the compiled-in default; `new(...)` with a caller-supplied set is unaffected by the default + +## 5. DirectoryTrustAnchor impl and re-exports + +- [ ] 5.1 Implement `trusted_app_hash(H)`: `snapshot_for(H)` then return its `app_hash` +- [ ] 5.2 Implement `trusted_digest(H)`: `snapshot_for(H)` then return `TrustedDigest { height: H, accumulator }` (no ICS23 proof - the quorum attests the accumulator directly) +- [ ] 5.3 Expose the snapshot's `node_identities_hash` for a given height via an anchor-specific accessor (not part of the shared `DirectoryTrustAnchor` trait, so `ProvenTrustAnchor` / `LightClientAnchor` are untouched) +- [ ] 5.4 Re-export `AttestedTrustAnchor`, `AttestationSource`, `SignedDigestSnapshot`, `DigestSnapshot` from `src/anchor/mod.rs` + +## 6. Data-source-agnostic whole-directory verification + +- [ ] 6.1 Extract the body of `DirectoryClient::verified_directory` (digest recompute, per-entry authorship attribution) into a function that accepts pre-fetched `records` and `node_identities`, plus the trusted `accumulator` and `node_identities_hash`, and needs no `CosmWasmClient` at all +- [ ] 6.2 Add a node-identity hash recompute check (mirroring `recompute_accumulator`) using the encoder from 1.4, failing closed (`DigestMismatch`-equivalent) on any mismatch +- [ ] 6.3 Make `DirectoryClient::verified_directory` a thin wrapper: fetch records + node identities via `self.client` as today, then call the extracted function - existing RPC-backed callers (any anchor) see no behavior change +- [ ] 6.4 Add a new entry point (free function or method not requiring `C: CosmWasmClient`) that verifies a whole-directory fetch given caller-supplied records + node identities and an anchor whose snapshot carries `node_identities_hash` - errors clearly if the anchor does not provide one (e.g. `NodeIdentitiesHashUnavailable`) rather than skipping authorship verification silently + +## 7. Error handling + +- [ ] 7.1 Add to `DirectoryClientError`: `QuorumNotReached { needed: usize, agreed: usize }`, `NoQuorumSnapshotForHeight(u64)`, `InvalidQuorumConfig { quorum: usize, signers: usize }`, `NodeIdentitiesHashUnavailable`, and an attestation-transport / decode variant as needed + +## 8. Tests (mock transport) + +- [ ] 8.1 Add a `MockAttestationSource` (in-memory, serves pre-registered latest + per-height signed snapshots; records call log) - mirror the `MockRpcClient` pattern; helper to build a `SignedDigestSnapshot` from a seeded `KeyPair` +- [ ] 8.2 Unit test: K distinct trusted signers agreeing on identical values yields a trusted snapshot; `trusted_app_hash` and `trusted_digest` return the attested values +- [ ] 8.3 Unit test: fewer than K valid agreeing signers returns `QuorumNotReached` +- [ ] 8.4 Unit test: a duplicated signer key is counted once (does not reach quorum on its own) +- [ ] 8.5 Unit test: an attestation from an untrusted signer, or with an invalid signature, or with a mismatched chain-id / contract, is ignored (not counted toward quorum) +- [ ] 8.6 Unit test: signers disagreeing on `(app_hash, accumulator, node_identities_hash)` such that no group reaches K is rejected +- [ ] 8.7 Unit test: `refresh()` pins the latest agreed height; a later `trusted_app_hash(H)` for a cached height is served without re-querying sources (call log) +- [ ] 8.8 Unit test: a recent past height within the window is verified via `snapshot_at`; a height the quorum cannot attest returns `NoQuorumSnapshotForHeight` +- [ ] 8.9 Unit test: `new` with `quorum > signers` or `quorum == 0` returns `InvalidQuorumConfig` +- [ ] 8.10 Unit test: whole-directory verification via the decoupled entry point (6.4) succeeds against caller-supplied records + node identities matching the trusted snapshot, and fails closed on a mismatch in either the accumulator or the node-identities hash +- [ ] 8.11 Unit test: the decoupled entry point (6.4) against an anchor without a `node_identities_hash` returns `NodeIdentitiesHashUnavailable` +- [ ] 8.12 Unit test: `DirectoryClient::verified_directory` (RPC-backed path, 6.3) is behaviorally unchanged for `ProvenTrustAnchor` / existing tests + +## 9. Verification + +- [ ] 9.1 `cargo test -p nym-directory-contract-common --lib` passes (new payload + node-identity-hash tests) +- [ ] 9.2 `cargo test -p nym-directory-client --lib` passes (attested anchor tests + decoupled verification tests + existing tests) +- [ ] 9.3 `cargo build -p nym-directory-client` and `cargo build -p nym-directory-client --features light-client` both succeed (attested anchor is not feature-gated and must build in both) From b0039c20c0876cdde95977f606042c520b01ebd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C4=99drzej=20Stuczy=C5=84ski?= Date: Tue, 7 Jul 2026 17:39:31 +0100 Subject: [PATCH 2/6] producing digest signing payload for the snapshot --- Cargo.lock | 4 + common/lthash/Cargo.toml | 10 + common/lthash/src/lib.rs | 27 ++ common/nym-directory-client/Cargo.toml | 5 +- .../src/anchor/attested.rs | 244 ++++++++++++++++++ common/nym-directory-client/src/verify.rs | 96 +++++++ nym-wallet/Cargo.lock | 24 +- .../directory-attested-anchor/design.md | 12 +- .../directory-attested-anchor/proposal.md | 7 +- .../directory-attested-anchor/tasks.md | 21 +- 10 files changed, 421 insertions(+), 29 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ee9c55b8f70..7f6eba1e237 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6851,6 +6851,7 @@ version = "1.21.3" dependencies = [ "anyhow", "async-trait", + "blake3", "cosmrs", "cw-storage-plus", "ics23", @@ -7560,8 +7561,11 @@ dependencies = [ name = "nym-lthash" version = "1.21.3" dependencies = [ + "bincode", "blake3", "digest 0.10.7", + "serde", + "serde_bytes", "sha3 0.10.9", ] diff --git a/common/lthash/Cargo.toml b/common/lthash/Cargo.toml index b1be7ac3fe8..afba11d2144 100644 --- a/common/lthash/Cargo.toml +++ b/common/lthash/Cargo.toml @@ -20,9 +20,19 @@ publish = true # pin (<1.8.4 keeps blake3 on digest 0.10). blake3 = { version = ">=1.7, <1.8.4", default-features = false, features = ["traits-preview"] } digest = { workspace = true } +# Not `workspace = true`, same reason as blake3 above (default-features = false for +# no_std). Optional and off by default so the wasm contract build (which never +# serializes an `LtHash`) does not pay for it; native consumers opt in via the `serde` +# feature. +serde = { version = "1.0.219", default-features = false, optional = true } +serde_bytes = { version = "0.11.17", default-features = false, optional = true } [dev-dependencies] sha3 = { workspace = true } +bincode = { workspace = true } + +[features] +serde = ["dep:serde", "dep:serde_bytes"] [lints] workspace = true diff --git a/common/lthash/src/lib.rs b/common/lthash/src/lib.rs index 7a270a7ef96..cf0d286bc72 100644 --- a/common/lthash/src/lib.rs +++ b/common/lthash/src/lib.rs @@ -67,6 +67,21 @@ impl LtHash { } } +#[cfg(feature = "serde")] +impl serde::Serialize for LtHash { + fn serialize(&self, serializer: S) -> Result { + serde_bytes::serialize(&self.to_bytes(), serializer) + } +} + +#[cfg(feature = "serde")] +impl<'de, H> serde::Deserialize<'de> for LtHash { + fn deserialize>(deserializer: D) -> Result { + let bytes: [u8; DIGEST_LEN] = serde_bytes::deserialize(deserializer)?; + Ok(LtHash::from_bytes(&bytes)) + } +} + impl LtHash { fn expand(element: &[u8]) -> [u16; ELEMENTS] { let mut hasher = H::default(); @@ -213,6 +228,18 @@ mod tests { assert_ne!(only_a, LtHash::::new()); } + #[cfg(feature = "serde")] + #[test] + fn serde_round_trips_through_bincode() { + let mut lt = LtHash16::new(); + lt.add(b"alice"); + lt.add(b"bob"); + + let bytes = bincode::serialize(<).unwrap(); + let restored: LtHash16 = bincode::deserialize(&bytes).unwrap(); + assert_eq!(lt, restored); + } + #[test] fn invariants_blake3() { check_invariants::(); diff --git a/common/nym-directory-client/Cargo.toml b/common/nym-directory-client/Cargo.toml index 7b346771fba..a9cdacd491c 100644 --- a/common/nym-directory-client/Cargo.toml +++ b/common/nym-directory-client/Cargo.toml @@ -15,15 +15,16 @@ publish = true [dependencies] thiserror = { workspace = true } async-trait = { workspace = true } +blake3 = { workspace = true } ics23 = { workspace = true, features = ["host-functions"] } prost = { workspace = true } cosmrs = { workspace = true } -serde = { workspace = true } +serde = { workspace = true, features = ["derive"] } tracing = { workspace = true } tokio = { workspace = true, features = ["sync"] } tendermint-light-client = { workspace = true, optional = true } -nym-lthash = { workspace = true } +nym-lthash = { workspace = true, features = ["serde"] } nym-crypto = { workspace = true, features = ["asymmetric"] } nym-validator-client = { workspace = true, features = ["http-client"] } nym-directory-contract-common = { workspace = true } diff --git a/common/nym-directory-client/src/anchor/attested.rs b/common/nym-directory-client/src/anchor/attested.rs index 9285ae3ffc1..2f8d60c650e 100644 --- a/common/nym-directory-client/src/anchor/attested.rs +++ b/common/nym-directory-client/src/anchor/attested.rs @@ -1,2 +1,246 @@ // Copyright 2026 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 + +use cosmrs::AccountId; +use cosmrs::tendermint::chain; +use nym_lthash::LtHash16; +use nym_validator_client::nyxd::Height; +use nym_validator_client::nyxd::hash::AppHash; +use serde::{Deserialize, Serialize}; + +/// Domain-separation tag for [`digest_snapshot_signing_payload`], so a snapshot +/// signature can never be interpreted as a `node_signing_payload` signature (which +/// carries no tag of its own), even for a signer whose identity key is used for both. +const DIGEST_SNAPSHOT_DOMAIN_TAG: &[u8] = b"nym-directory-digest-snapshot-v1"; + +#[derive(Serialize, Deserialize)] +pub struct DigestSnapshot { + /// The chain this attestation is scoped to, so a signature cannot be replayed + /// against a different chain. + chain_id: chain::Id, + + /// The directory contract this attestation is scoped to, so a signature cannot be + /// replayed against a different contract instance. + directory_contract: AccountId, + + /// The block height every other field attests to. + height: Height, + + /// The block `app_hash` at `height` - the ICS23 fallback root for single-entry reads. + #[serde(with = "cosmrs::tendermint::serializers::apphash")] + app_hash: AppHash, + + /// The directory contract's LtHash accumulator at `height`. + accumulator: LtHash16, + + /// Hash over the current `NodeId -> ed25519 identity` mapping at `height` + /// (see [`crate::verify::node_identities_hash`]). + node_identities_hash: [u8; 32], +} + +impl DigestSnapshot { + pub(crate) fn signing_payload(&self) -> Vec { + digest_snapshot_signing_payload( + self.chain_id.as_ref(), + &self.directory_contract, + self.height, + &self.app_hash, + &self.accumulator, + &self.node_identities_hash, + ) + } +} + +/// Append `bytes` prefixed with its u32 little-endian length, so adjacent +/// variable-length fields cannot be confused with one another. Mirrors +/// `nym_directory_contract_common::helpers::push_len_prefixed`'s framing (private to +/// that crate); reproduced here since it is the only encoder in this crate that needs it. +fn push_len_prefixed(buf: &mut Vec, bytes: &[u8]) { + buf.extend_from_slice(&(bytes.len() as u32).to_le_bytes()); + buf.extend_from_slice(bytes); +} + +/// The exact bytes a nym-api signs when attesting a directory snapshot: the block +/// `app_hash`, the directory's LtHash `accumulator`, and a hash over the current +/// `NodeId -> ed25519 identity` mapping (see +/// [`crate::verify::node_identities_hash`]), all bound to a chain-id, contract address, +/// and height so a signature cannot be replayed across chains, contract instances, or +/// heights. +pub(crate) fn digest_snapshot_signing_payload( + chain_id: &str, + contract: &AccountId, + height: Height, + app_hash: &AppHash, + accumulator: &LtHash16, + node_identities_hash: &[u8; 32], +) -> Vec { + let mut buf = Vec::new(); + buf.extend_from_slice(DIGEST_SNAPSHOT_DOMAIN_TAG); + push_len_prefixed(&mut buf, chain_id.as_bytes()); + push_len_prefixed(&mut buf, &contract.to_bytes()); + buf.extend_from_slice(&height.value().to_le_bytes()); + push_len_prefixed(&mut buf, app_hash.as_bytes()); + push_len_prefixed(&mut buf, &accumulator.to_bytes()); + buf.extend_from_slice(node_identities_hash); + buf +} + +#[cfg(test)] +mod tests { + use super::*; + use std::str::FromStr; + + fn contract() -> AccountId { + AccountId::from_str("n17srjznxl9dvzdkpwpw24gg668wc73val88a6m5ajg6ankwvz9wtst0cznr").unwrap() + } + + fn other_contract() -> AccountId { + AccountId::from_str("n1jw6mp7d5xqc7w6xm79lha27glmd0vdt3l9artf").unwrap() + } + + fn app_hash(byte: u8) -> AppHash { + AppHash::try_from(vec![byte; 32]).unwrap() + } + + #[test] + fn digest_snapshot_payload_is_deterministic_and_field_sensitive() { + let contract = contract(); + let acc = LtHash16::new(); + let node_hash = [9u8; 32]; + let base = digest_snapshot_signing_payload( + "nyx-testnet", + &contract, + Height::from(100u32), + &app_hash(1), + &acc, + &node_hash, + ); + assert_eq!( + base, + digest_snapshot_signing_payload( + "nyx-testnet", + &contract, + Height::from(100u32), + &app_hash(1), + &acc, + &node_hash, + ) + ); + assert_ne!( + base, + digest_snapshot_signing_payload( + "nyx-mainnet", + &contract, + Height::from(100u32), + &app_hash(1), + &acc, + &node_hash, + ) + ); + assert_ne!( + base, + digest_snapshot_signing_payload( + "nyx-testnet", + &other_contract(), + Height::from(100u32), + &app_hash(1), + &acc, + &node_hash, + ) + ); + assert_ne!( + base, + digest_snapshot_signing_payload( + "nyx-testnet", + &contract, + Height::from(101u32), + &app_hash(1), + &acc, + &node_hash, + ) + ); + assert_ne!( + base, + digest_snapshot_signing_payload( + "nyx-testnet", + &contract, + Height::from(100u32), + &app_hash(2), + &acc, + &node_hash, + ) + ); + let mut other_acc = LtHash16::new(); + other_acc.add(b"leaf"); + assert_ne!( + base, + digest_snapshot_signing_payload( + "nyx-testnet", + &contract, + Height::from(100u32), + &app_hash(1), + &other_acc, + &node_hash, + ) + ); + let mut other_node_hash = node_hash; + other_node_hash[0] ^= 1; + assert_ne!( + base, + digest_snapshot_signing_payload( + "nyx-testnet", + &contract, + Height::from(100u32), + &app_hash(1), + &acc, + &other_node_hash, + ) + ); + } + + #[test] + fn digest_snapshot_payload_length_prefix_disambiguates() { + // (chain-id "ab", contract-derived bytes) framing must not let adjacent + // variable-length fields bleed into one another; exercised here via chain-id + // vs. the contract's encoded bytes rather than two strings of our own choosing, + // since `contract` is a real bech32 address. + let acc = LtHash16::new(); + let node_hash = [0u8; 32]; + assert_ne!( + digest_snapshot_signing_payload( + "ab", + &contract(), + Height::from(0u32), + &app_hash(0), + &acc, + &node_hash, + ), + digest_snapshot_signing_payload( + "a", + &other_contract(), + Height::from(0u32), + &app_hash(0), + &acc, + &node_hash, + ), + ); + } + + #[test] + fn digest_snapshot_payload_is_domain_tagged() { + let payload = digest_snapshot_signing_payload( + "chain", + &contract(), + Height::from(1u32), + &app_hash(7), + &LtHash16::new(), + &[7u8; 32], + ); + assert!(payload.starts_with(DIGEST_SNAPSHOT_DOMAIN_TAG)); + + // a representative node-entry payload never starts with the snapshot's domain + // tag, so the two signature domains cannot be confused + let node_payload = nym_directory_contract_common::node_signing_payload(1, "x", 1, b"y"); + assert!(!node_payload.starts_with(DIGEST_SNAPSHOT_DOMAIN_TAG)); + } +} diff --git a/common/nym-directory-client/src/verify.rs b/common/nym-directory-client/src/verify.rs index 580edbfb486..f5719075b05 100644 --- a/common/nym-directory-client/src/verify.rs +++ b/common/nym-directory-client/src/verify.rs @@ -91,6 +91,32 @@ pub fn recompute_accumulator(records: &[DirectoryEntryRecord]) -> LtHash16 { acc } +/// Canonical hash over a set of `(NodeId, identity)` pairs - the node-identity binding a +/// nym-api attests alongside the directory accumulator (see the attested anchor), so +/// whole-directory retrieval can verify entry authorship without a live chain +/// connection. Sorted internally by `NodeId`, so the caller's iteration order does not +/// affect the result. +/// +/// Every pair contributes a fixed-width `NodeId` (big-endian) followed by the identity's +/// raw bytes, so - unlike `node_signing_payload`'s variable-length fields - no +/// length-prefixing is needed: every record is the same width, so the total buffer +/// length alone fixes the record count, and a record's position alone fixes its field +/// boundaries. +pub fn node_identities_hash<'a>( + identities: impl Iterator, +) -> [u8; 32] { + let mut pairs: Vec<_> = identities.collect(); + pairs.sort_unstable_by_key(|(node_id, _)| *node_id); + + let mut buf = Vec::new(); + for (node_id, identity) in pairs { + buf.extend_from_slice(&node_id.to_be_bytes()); + buf.extend_from_slice(&identity.to_bytes()); + } + + blake3::hash(&buf).into() +} + /// Whether `entry`'s stored ed25519 signature verifies as node-authored: the signature /// over the canonical [`node_signing_payload`] must validate under `identity`. pub(crate) fn node_signature_verifies( @@ -231,4 +257,74 @@ mod tests { kp.public_key() )); } + + #[test] + fn node_identities_hash_is_deterministic() { + let a = *keypair(1).public_key(); + let b = *keypair(2).public_key(); + let pairs = [(1u32, a), (2, b)]; + assert_eq!( + node_identities_hash(pairs.iter()), + node_identities_hash(pairs.iter()) + ); + } + + #[test] + fn node_identities_hash_is_order_independent() { + let a = *keypair(1).public_key(); + let b = *keypair(2).public_key(); + let c = *keypair(3).public_key(); + let forward = [(1, a), (2, b), (3, c)]; + let shuffled = [(3, c), (1, a), (2, b)]; + assert_eq!( + node_identities_hash(forward.iter()), + node_identities_hash(shuffled.iter()) + ); + } + + #[test] + fn node_identities_hash_is_sensitive_to_node_id_change() { + let a = *keypair(1).public_key(); + let b = *keypair(2).public_key(); + let base = [(1, a), (2, b)]; + let changed = [(1, a), (3, b)]; + assert_ne!( + node_identities_hash(base.iter()), + node_identities_hash(changed.iter()) + ); + } + + #[test] + fn node_identities_hash_is_sensitive_to_identity_change() { + let a = *keypair(1).public_key(); + let b = *keypair(2).public_key(); + let other = *keypair(3).public_key(); + let base = [(1, a), (2, b)]; + let changed = [(1, a), (2, other)]; + assert_ne!( + node_identities_hash(base.iter()), + node_identities_hash(changed.iter()) + ); + } + + #[test] + fn node_identities_hash_is_sensitive_to_membership_change() { + let a = *keypair(1).public_key(); + let b = *keypair(2).public_key(); + let c = *keypair(3).public_key(); + let base = [(1, a), (2, b)]; + let extra = [(1, a), (2, b), (3, c)]; + assert_ne!( + node_identities_hash(base.iter()), + node_identities_hash(extra.iter()) + ); + } + + #[test] + fn empty_node_identities_mapping_hashes_deterministically() { + assert_eq!( + node_identities_hash(Vec::new().iter()), + node_identities_hash(Vec::new().iter()) + ); + } } diff --git a/nym-wallet/Cargo.lock b/nym-wallet/Cargo.lock index 9d555cace03..9898e5ee2b3 100644 --- a/nym-wallet/Cargo.lock +++ b/nym-wallet/Cargo.lock @@ -267,7 +267,7 @@ dependencies = [ "objc2-foundation 0.3.0", "parking_lot", "percent-encoding", - "windows-sys 0.52.0", + "windows-sys 0.59.0", "wl-clipboard-rs", "x11rb", ] @@ -723,7 +723,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33415e24172c1b7d6066f6d999545375ab8e1d95421d6784bdfff9496f292387" dependencies = [ "bitcoin_hashes", - "rand 0.7.3", + "rand 0.8.6", "rand_core 0.6.4", "serde", "unicode-normalization", @@ -1230,7 +1230,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" dependencies = [ "lazy_static", - "windows-sys 0.48.0", + "windows-sys 0.59.0", ] [[package]] @@ -2038,7 +2038,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.0", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2364,7 +2364,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -3925,7 +3925,7 @@ checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9" dependencies = [ "hermit-abi 0.5.0", "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -6759,7 +6759,7 @@ dependencies = [ "once_cell", "socket2 0.5.9", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -7246,7 +7246,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -7259,7 +7259,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -7360,7 +7360,7 @@ dependencies = [ "security-framework 3.5.1", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -8715,7 +8715,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -10015,7 +10015,7 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.59.0", ] [[package]] diff --git a/openspec/changes/directory-attested-anchor/design.md b/openspec/changes/directory-attested-anchor/design.md index 14ab560bf17..efbd438f655 100644 --- a/openspec/changes/directory-attested-anchor/design.md +++ b/openspec/changes/directory-attested-anchor/design.md @@ -50,9 +50,14 @@ The `node_identities_hash` addition specifically is what makes whole-directory r Alternative considered - attest `app_hash` only, then ICS23-derive the accumulator and separately RPC-fetch node identities. Rejected for the same reason as before (weakens the anchor to a generic header oracle, forces it to hold an RPC) and additionally fails to deliver a genuinely RPC-free retrieval path, which is now a stated goal. -### D3: Canonical signing payload in the shared crate; signed types in the client +### D3: Both canonical encoders live in `nym-directory-client`, not a contract-common crate -The exact bytes a nym-api signs are produced by `digest_snapshot_signing_payload(chain_id, contract, height, app_hash, accumulator, node_identities_hash) -> Vec` in `nym-directory-contract-common`, next to `node_signing_payload` and reusing its `push_len_prefixed` framing, with a distinct domain-separation tag so a snapshot signature can never be confused with a node-entry signature. The canonical encoder for `node_identities_hash` itself (hashing the sorted `(NodeId, identity)` set) is a separate, small function whose home is a mixnet-contract-common concern (it hashes mixnet bond data, not directory data) rather than living in the directory's shared crate - exact placement is an implementation-time call, not a design fork. This mirrors the existing split: canonical encoding lives in shared crates (single source of truth for producer and consumer), signed-wrapper type and verification logic live in the client (`attested.rs`). +Revisited mid-implementation. `node_signing_payload` lives in `nym-directory-contract-common` because the contract itself reconstructs those bytes on-chain when verifying a node-entry signature - a genuine contract/client shared boundary. Neither `digest_snapshot_signing_payload` nor `node_identities_hash` has a contract-side consumer: their only two consumers are both off-chain peers of the digest-snapshot attestation protocol - the not-yet-built nym-api producer (signs) and this crate (verifies) - the identical pairing `recompute_accumulator` already serves from `verify.rs`. So both now live in `nym-directory-client`: + +- `digest_snapshot_signing_payload(chain_id: &str, contract: &AccountId, height: Height, app_hash: &AppHash, accumulator: &LtHash16, node_identities_hash: &[u8; 32]) -> Vec` in `src/anchor/attested.rs`, `pub(crate)` since nothing outside this crate consumes it yet. Uses the crate's own real types (`AccountId`, `LtHash16`, `AppHash`) rather than primitive-typed workarounds, since this crate already depends on `cosmrs` / `nym-lthash` / `nym-crypto`. Has its own small local length-prefixing helper (mirroring, not reusing, `nym_directory_contract_common::helpers::push_len_prefixed`, which is private to that crate) and a distinct domain-separation tag so a snapshot signature can never be confused with a node-entry signature. +- `node_identities_hash` in `src/verify.rs`, next to `recompute_accumulator` - takes `(NodeId, ed25519::PublicKey)` pairs directly (this crate already depends on `nym-crypto`), sorts internally, and hashes with `blake3` (a new direct dependency of this crate; already transitively present via `nym-lthash`). + +This intentionally does *not* try to pre-build a neutral shared-crate home for a producer that doesn't exist yet and isn't scoped in this change (see the deferred nym-api producer endpoint in Non-Goals). `nym-api` already depends on multiple `-client` crates for shared chain logic (e.g. `nym-validator-client`), so a future producer depending on `nym-directory-client` - or extracting a shared piece once its real constraints are known - is a reasonable question to leave to that follow-up rather than deciding now. ### D4: Sybil resistance via a configured signer set and a quorum threshold @@ -101,7 +106,7 @@ This change does not implement either; it only flags the dependency so D8's over ## Migration Plan -1. Add `digest_snapshot_signing_payload` (now including `node_identities_hash`) to `nym-directory-contract-common`, and the node-identity-mapping canonical hash encoder to its chosen home, both with unit tests mirroring the `node_signing_payload` determinism / field-sensitivity tests. +1. Add `digest_snapshot_signing_payload` to `src/anchor/attested.rs` and `node_identities_hash` to `src/verify.rs` (both in `nym-directory-client`; see D3), with unit tests mirroring the `node_signing_payload` determinism / field-sensitivity tests. 2. Implement `SignedDigestSnapshot`, the `AttestationSource` trait, `AttestedTrustAnchor`, and the default anchor constants in `src/anchor/attested.rs`. 3. Re-export the public types from `src/anchor/mod.rs`. 4. Add the new error variants to `DirectoryClientError`. @@ -114,7 +119,6 @@ This change does not implement either; it only flags the dependency so D8's over - Retained-window size: how many previous snapshots should producers keep? Tied to the sphinx-key rotation range width and the maximum expected client lag. - Freshness: is height-vs-chain-tip lag sufficient, or does the attestation eventually need an `issued_at` / expiry field? - Default anchor size/composition: exactly how many Nym-SA keys, and what quorum threshold, ships as the default? (Implementation-time call, not blocking design.) -- Node-identity-hash encoder home: `nym-mixnet-contract-common` or elsewhere - implementation-time call. ## Future direction (deferred, not in scope for this change) diff --git a/openspec/changes/directory-attested-anchor/proposal.md b/openspec/changes/directory-attested-anchor/proposal.md index 919a60218c4..e96364b8844 100644 --- a/openspec/changes/directory-attested-anchor/proposal.md +++ b/openspec/changes/directory-attested-anchor/proposal.md @@ -14,7 +14,7 @@ A third direction - generalizing attestation from "the whole directory" to *any* ## What Changes - Add `AttestedTrustAnchor` in `common/nym-directory-client/src/anchor/attested.rs`, a third `DirectoryTrustAnchor` implementation backed by a K-of-N quorum of configured nym-api identity keys. -- Define the signed snapshot attestation: the canonical signing-payload encoding lives in `nym-directory-contract-common` (next to `node_signing_payload`); the signed wrapper type and quorum verification live in the client crate. The snapshot commits to `{height, app_hash, accumulator, node_identities_hash}` - the directory digest AND a hash over the current `NodeId -> ed25519 identity` bindings from the mixnet contract, both computed by the signing nym-api from its own trusted chain access. +- Define the signed snapshot attestation: the canonical signing-payload encoding, the signed wrapper type, and quorum verification all live in `nym-directory-client` itself (see `design.md` D3 - revisited mid-implementation; neither encoder has a contract-side consumer, so a contract-common crate is not the right home). The snapshot commits to `{height, app_hash, accumulator, node_identities_hash}` - the directory digest AND a hash over the current `NodeId -> ed25519 identity` bindings from the mixnet contract, both computed by the signing nym-api from its own trusted chain access. - Ship a small, hardcoded default anchor set (Nym-SA-owned nym-api identity keys/endpoints) as the out-of-the-box `trusted_signers`/sources for `AttestedTrustAnchor`, overridable by any caller who wants a different trust root. - Decouple whole-directory verification from the client's own RPC connection: given a trusted snapshot, directory entries and node identities fetched from *any* untrusted source can be verified by local hash-recompute (`recompute_accumulator`, already existing, plus a new equivalent for node identities) with no chain query at all. The existing RPC-backed convenience path on `DirectoryClient` is preserved unchanged for callers that do hold a chain connection. - Add an `AttestationSource` transport trait (fetch latest snapshot / fetch snapshot at a height) so the anchor is transport-agnostic and testable with a mock; the concrete HTTP transport and the nym-api producer endpoint are deferred to a follow-up. @@ -34,9 +34,8 @@ A third direction - generalizing attestation from "the whole directory" to *any* ## Impact -- `common/nym-directory-client/`: new `src/anchor/attested.rs` (anchor + attestation types + `AttestationSource` trait + default anchor constants), updated `src/anchor/mod.rs` (re-exports), new error variants in `src/error.rs`, and a new data-source-agnostic verification entry point in `src/client.rs` (existing RPC-backed methods become thin wrappers around it). No new runtime deps (reuses `nym-crypto` ed25519 + `async-trait` + `serde`), so no feature gate. -- `common/cosmwasm-smart-contracts/directory-contract/` (`nym-directory-contract-common`): new `digest_snapshot_signing_payload` canonical encoder next to `node_signing_payload`, extended to bind `node_identities_hash` alongside `app_hash`/`accumulator`, so a future producer reproduces identical bytes. -- A new canonical encoder for the `NodeId -> ed25519 identity` mapping hash (home TBD at implementation time - likely `nym-mixnet-contract-common`, next to the bond types it hashes). +- `common/nym-directory-client/`: `src/anchor/attested.rs` gains `digest_snapshot_signing_payload` (the canonical signing-payload encoder, `pub(crate)`) alongside the anchor + attestation types + `AttestationSource` trait + default anchor constants; `src/verify.rs` gains `node_identities_hash` next to `recompute_accumulator`; updated `src/anchor/mod.rs` (re-exports), new error variants in `src/error.rs`, and a new data-source-agnostic verification entry point in `src/client.rs` (existing RPC-backed methods become thin wrappers around it). One new direct dependency, `blake3` (already present transitively via `nym-lthash`; no feature gate needed, `nym-crypto`/`cosmrs`/`nym-lthash` were already dependencies). +- No changes to any contract-common crate (`nym-directory-contract-common`, `nym-mixnet-contract-common`): neither new encoder has a contract-side consumer, so they stay in the client crate that actually uses them (see `design.md` D3). - `verify.rs` / `client.rs` gain the decoupled verification path described above; `proof.rs`, `key.rs`, the contract logic, and `nym-api` are otherwise untouched (the producer endpoint is a follow-up). - Consumers that want attested mode construct `AttestedTrustAnchor::new(sources, trusted_signers, quorum, chain_id, contract)` (or the default-anchor constructor), call `latest_snapshot_height()`, then drive `verified_directory(height)` as before, or the new no-RPC verification entry point if they sourced the data themselves. diff --git a/openspec/changes/directory-attested-anchor/tasks.md b/openspec/changes/directory-attested-anchor/tasks.md index 2d044c3cdc6..ef3bc228dcb 100644 --- a/openspec/changes/directory-attested-anchor/tasks.md +++ b/openspec/changes/directory-attested-anchor/tasks.md @@ -1,10 +1,17 @@ -## 1. Canonical attestation encoding (shared crates) - -- [ ] 1.1 Add `digest_snapshot_signing_payload(chain_id: &str, contract: &AccountId, height: u64, app_hash: &[u8], accumulator: &[u8; DIGEST_LEN], node_identities_hash: &[u8; 32]) -> Vec` to `common/cosmwasm-smart-contracts/directory-contract/src/helpers.rs`, reusing `push_len_prefixed` and prefixing a distinct domain-separation tag (so a snapshot signature can never collide with a `node_signing_payload` signature) -- [ ] 1.2 Re-export it from `nym-directory-contract-common`'s public surface -- [ ] 1.3 Unit tests: payload is deterministic and field-sensitive (differs on any of chain-id / contract / height / app_hash / accumulator / node_identities_hash); length-prefix framing disambiguates adjacent variable-length fields; domain tag differs from the node-entry payload -- [ ] 1.4 Add a canonical hash encoder for the `NodeId -> ed25519 identity` mapping (sorted, length-prefixed pairs, plain cryptographic hash - not an LtHash accumulator, since it is recomputed fresh each time rather than incrementally updated) in an appropriate shared crate (e.g. `nym-mixnet-contract-common`, next to the bond types it hashes) -- [ ] 1.5 Unit tests for the node-identity hash encoder: deterministic, sensitive to any `(node_id, identity)` change, order-independent (sorts internally so caller iteration order does not matter) +## 1. Canonical attestation encoding + +Lives in `nym-directory-client` itself, not a contract-common crate: neither function is +ever consumed by a contract, only by this crate (verifying) and the not-yet-built nym-api +producer (signing) - the same two-off-chain-peers pairing `recompute_accumulator` already +serves from `verify.rs`. Placement revisited mid-implementation (see `design.md` D3); the +producer, when it lands, can decide then whether to depend on this crate or extract a +shared piece, once its real constraints are known. + +- [x] 1.1 Add `digest_snapshot_signing_payload(chain_id: &str, contract: &AccountId, height: Height, app_hash: &AppHash, accumulator: &LtHash16, node_identities_hash: &[u8; 32]) -> Vec` to `common/nym-directory-client/src/anchor/attested.rs`, with its own local length-prefixing helper and a domain-separation tag (so a snapshot signature can never collide with a `node_signing_payload` signature) +- [x] 1.2 N/A - not a shared crate, no re-export surface; `pub(crate)` within `nym-directory-client`, consumed by `attested.rs` itself (task 2/3) +- [x] 1.3 Unit tests: payload is deterministic and field-sensitive (differs on any of chain-id / contract / height / app_hash / accumulator / node_identities_hash); length-prefix framing disambiguates adjacent variable-length fields; domain tag differs from a representative `node_signing_payload` output +- [x] 1.4 Add `node_identities_hash` (sorted, fixed-width-per-record, plain `blake3` hash - not an LtHash accumulator, since it is recomputed fresh each time rather than incrementally updated) to `common/nym-directory-client/src/verify.rs`, next to `recompute_accumulator` +- [x] 1.5 Unit tests for `node_identities_hash`: deterministic, sensitive to any `(node_id, identity)` or membership change, order-independent (sorts internally so caller iteration order does not matter) ## 2. Attestation types and transport trait (client crate) From 3f8c0b28a8fca2ff785398513c2d08eeabd8cf94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C4=99drzej=20Stuczy=C5=84ski?= Date: Wed, 8 Jul 2026 12:22:41 +0100 Subject: [PATCH 3/6] initial quorum agreement on snapshot data --- Cargo.lock | 2 + common/lthash/src/lib.rs | 7 + common/nym-directory-client/Cargo.toml | 2 + .../src/anchor/attested.rs | 727 +++++++++++++++++- common/nym-directory-client/src/error.rs | 19 + .../directory-attested-anchor/design.md | 20 +- .../directory-attested-anchor/tasks.md | 36 +- 7 files changed, 795 insertions(+), 18 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7f6eba1e237..8873b19d196 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6854,6 +6854,7 @@ dependencies = [ "blake3", "cosmrs", "cw-storage-plus", + "futures", "ics23", "nym-crypto", "nym-directory-contract-common", @@ -6862,6 +6863,7 @@ dependencies = [ "nym-test-utils", "nym-validator-client", "prost 0.13.5", + "rand 0.8.6", "serde", "tendermint-light-client", "thiserror 2.0.18", diff --git a/common/lthash/src/lib.rs b/common/lthash/src/lib.rs index cf0d286bc72..471609ec5ba 100644 --- a/common/lthash/src/lib.rs +++ b/common/lthash/src/lib.rs @@ -17,6 +17,7 @@ //! * : Securing Update Propagation with Homomorphic Hashing //! * : C++ implementation of the LtHash used at Facebook. +use core::hash::{Hash, Hasher}; use core::marker::PhantomData; use digest::{Digest, ExtendableOutput, Output, Update, XofReader}; @@ -150,6 +151,12 @@ impl PartialEq for LtHash { } } +impl Hash for LtHash { + fn hash(&self, state: &mut H) { + self.state.hash(state); + } +} + impl Eq for LtHash {} impl core::fmt::Debug for LtHash { diff --git a/common/nym-directory-client/Cargo.toml b/common/nym-directory-client/Cargo.toml index a9cdacd491c..57dfc47945b 100644 --- a/common/nym-directory-client/Cargo.toml +++ b/common/nym-directory-client/Cargo.toml @@ -16,9 +16,11 @@ publish = true thiserror = { workspace = true } async-trait = { workspace = true } blake3 = { workspace = true } +futures = { workspace = true } ics23 = { workspace = true, features = ["host-functions"] } prost = { workspace = true } cosmrs = { workspace = true } +rand = { workspace = true } serde = { workspace = true, features = ["derive"] } tracing = { workspace = true } tokio = { workspace = true, features = ["sync"] } diff --git a/common/nym-directory-client/src/anchor/attested.rs b/common/nym-directory-client/src/anchor/attested.rs index 2f8d60c650e..d69d9cda5ce 100644 --- a/common/nym-directory-client/src/anchor/attested.rs +++ b/common/nym-directory-client/src/anchor/attested.rs @@ -1,19 +1,27 @@ // Copyright 2026 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 +use crate::error::DirectoryClientError; +use async_trait::async_trait; use cosmrs::AccountId; use cosmrs::tendermint::chain; +use futures::future::join_all; +use nym_crypto::asymmetric::ed25519; use nym_lthash::LtHash16; use nym_validator_client::nyxd::Height; use nym_validator_client::nyxd::hash::AppHash; +use rand::seq::SliceRandom; use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::hash::{Hash, Hasher}; +use tokio::sync::Mutex; /// Domain-separation tag for [`digest_snapshot_signing_payload`], so a snapshot /// signature can never be interpreted as a `node_signing_payload` signature (which /// carries no tag of its own), even for a signer whose identity key is used for both. const DIGEST_SNAPSHOT_DOMAIN_TAG: &[u8] = b"nym-directory-digest-snapshot-v1"; -#[derive(Serialize, Deserialize)] +#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct DigestSnapshot { /// The chain this attestation is scoped to, so a signature cannot be replayed /// against a different chain. @@ -38,6 +46,17 @@ pub struct DigestSnapshot { node_identities_hash: [u8; 32], } +impl Hash for DigestSnapshot { + fn hash(&self, state: &mut H) { + self.chain_id.hash(state); + self.directory_contract.as_ref().hash(state); + self.height.hash(state); + self.app_hash.as_ref().hash(state); + self.accumulator.hash(state); + self.node_identities_hash.hash(state); + } +} + impl DigestSnapshot { pub(crate) fn signing_payload(&self) -> Vec { digest_snapshot_signing_payload( @@ -51,6 +70,284 @@ impl DigestSnapshot { } } +/// A [`DigestSnapshot`] as published by a nym-api (or a nym-node), together with its signer and +/// signature over the snapshot's canonical signing payload. +#[derive(Clone, Serialize, Deserialize)] +pub struct SignedDigestSnapshot { + snapshot: DigestSnapshot, + + signer: ed25519::PublicKey, + + signature: ed25519::Signature, +} + +impl SignedDigestSnapshot { + /// Whether this attestation is trustworthy on its own: `signer` is in `trusted`, + /// the snapshot is scoped to `chain_id` and `contract`, and the signature verifies + /// over the canonical signing payload. Says nothing about quorum - that is the + /// anchor's job, counting distinct signers across many valid attestations like this + /// one. Mirrors `node_signature_verifies`. + pub(crate) fn verify( + &self, + trusted: &HashSet, + chain_id: &chain::Id, + contract: &AccountId, + ) -> bool { + if !trusted.contains(&self.signer) { + return false; + } + if &self.snapshot.chain_id != chain_id || &self.snapshot.directory_contract != contract { + return false; + } + self.signer + .verify(self.snapshot.signing_payload(), &self.signature) + .is_ok() + } +} + +/// A source of nym-api-signed directory snapshots, so the anchor is independent of any +/// particular transport and can be exercised with a mock. +#[async_trait] +pub trait AttestationSource { + /// This source's ed25519 identity key. + fn identity(&self) -> ed25519::PublicKey; + + /// This source's latest signed snapshot. + async fn latest_snapshot(&self) -> Result; + + /// This source's signed snapshot at a specific height, if still within its + /// retained window. + async fn snapshot_at( + &self, + height: Height, + ) -> Result; +} + +/// A quorum-agreed `app_hash`, digest accumulator, and node-identity hash for a +/// specific height - the trusted output of [`AttestedTrustAnchor::reach_quorum`]. +#[derive(Clone, Debug)] +struct TrustedSnapshot { + app_hash: AppHash, + accumulator: LtHash16, + node_identities_hash: [u8; 32], +} + +impl TrustedSnapshot { + fn from_snapshot(snapshot: DigestSnapshot) -> Self { + Self { + app_hash: snapshot.app_hash, + accumulator: snapshot.accumulator, + node_identities_hash: snapshot.node_identities_hash, + } + } +} + +struct AttestedTrustAnchorState { + snapshots: BTreeMap, + latest: Option, +} + +/// A [`DirectoryTrustAnchor`](crate::anchor::DirectoryTrustAnchor) backed by a K-of-N +/// quorum of nym-api identity keys signing directory snapshots, rather than a root key +/// or a light-client checkpoint. +pub struct AttestedTrustAnchor { + sources: Vec, + trusted_signers: HashSet, + quorum: usize, + chain_id: chain::Id, + directory_contract: AccountId, + + // we only need Mutex to be able to take &self without mutable reference + // there's no concurrent access anywhere + state: Mutex, +} + +impl AttestedTrustAnchor { + /// Constructs the anchor with a caller-supplied trust root. Rejects a degenerate + /// quorum (`quorum == 0` or `quorum > trusted_signers.len()`) - no network call is + /// made, so this cannot fail for any other reason. + pub fn new( + sources: Vec, + trusted_signers: HashSet, + quorum: usize, + chain_id: chain::Id, + directory_contract: AccountId, + ) -> Result { + if quorum == 0 || quorum > trusted_signers.len() { + return Err(DirectoryClientError::InvalidQuorumConfig { + quorum, + signers: trusted_signers.len(), + }); + } + + Ok(Self { + sources, + trusted_signers, + quorum, + chain_id, + directory_contract, + state: Mutex::new(AttestedTrustAnchorState { + snapshots: BTreeMap::new(), + latest: None, + }), + }) + } + + /// Filters `candidates` to valid attestations (see + /// [`SignedDigestSnapshot::verify`]), groups the survivors by + /// `(height, app_hash, accumulator, node_identities_hash)`, and accepts the *first* + /// group (in `candidates`' own order) to reach `quorum` distinct signers. + /// `agreed` in the error case is the largest distinct-signer count seen + /// across any single group, so callers can see how close the quorum came. + fn reach_quorum( + &self, + candidates: Vec, + ) -> Result<(Height, TrustedSnapshot), DirectoryClientError> { + // map between returned snapshot and signers which attested it + let mut groups: HashMap> = HashMap::new(); + + for candidate in candidates { + // disregard any inconsistent responses + if !candidate.verify( + &self.trusted_signers, + &self.chain_id, + &self.directory_contract, + ) { + continue; + } + + let snapshot = candidate.snapshot; + let entry = groups.entry(snapshot.clone()).or_default(); + entry.insert(candidate.signer); + + if entry.len() >= self.quorum { + return Ok((snapshot.height, TrustedSnapshot::from_snapshot(snapshot))); + } + } + + let best_agreed = groups.values().map(|s| s.len()).max().unwrap_or(0); + + Err(DirectoryClientError::QuorumNotReached { + needed: self.quorum, + agreed: best_agreed, + }) + } +} + +impl AttestedTrustAnchor +where + S: AttestationSource + Sync, +{ + /// Queries sources' [`AttestationSource::latest_snapshot`] in pseudorandom order and + /// returns the first successful response, untrusted at this point - just a height + /// hint. See [`Self::refresh`]. + async fn first_latest_snapshot(&self) -> Result { + // iterate through our sources in pseudorandom order and retrieve the latest snapshot from one of them + let mut indices: Vec<_> = (0..self.sources.len()).collect(); + let mut rng = rand::thread_rng(); + indices.shuffle(&mut rng); + + for i in indices { + if let Ok(snapshot) = self.sources[i].latest_snapshot().await { + return Ok(snapshot); + } + } + + Err(DirectoryClientError::QuorumNotReached { + needed: self.quorum, + agreed: 0, + }) + } + + /// Discovers and pins the quorum-agreed latest snapshot: seeds a height from the + /// first source that answers [`AttestationSource::latest_snapshot`] (untrusted - + /// a lying seed only wastes a round-trip, since acceptance still requires + /// `quorum` distinct trusted signers agreeing below), then asks every source's + /// [`AttestationSource::snapshot_at`] that same height, and + /// reaches quorum over the seed plus all of those responses. Pinning to one + /// concrete, already-observed height rather than comparing every source's own + /// independent "latest" avoids splitting honest sources across a cadence boundary. + pub async fn refresh(&self) -> Result { + let seed = self.first_latest_snapshot().await?; + let seed_signer = seed.signer; + let height = seed.snapshot.height; + + let mut candidates = vec![seed]; + candidates.extend( + join_all( + self.sources + .iter() + .filter(|s| s.identity() != seed_signer) + .map(|s| s.snapshot_at(height)), + ) + .await + .into_iter() + .filter_map(Result::ok), + ); + + let (height, trusted) = self.reach_quorum(candidates)?; + + let mut state = self.state.lock().await; + state.snapshots.insert(height, trusted); + state.latest = Some(height); + Ok(height) + } + + /// The cached latest quorum-agreed height, or [`Self::refresh`] if none is cached + /// yet. + pub async fn latest_snapshot_height(&self) -> Result { + if let Some(height) = self.state.lock().await.latest { + return Ok(height); + } + + self.refresh().await + } + + /// The quorum-agreed snapshot for a specific height a caller already has + /// independent reason to believe is real (not seeded via [`Self::refresh`]) - + /// served from cache if present, otherwise fetched fresh from every source's + /// [`AttestationSource::snapshot_at`]. Verifies the quorum actually agreed on the + /// *requested* height - a source could otherwise return a validly-signed + /// attestation for the wrong one. Because `height` only ever comes from a real + /// observed snapshot, a height the quorum cannot confirm has one coherent meaning, + /// [`DirectoryClientError::NoQuorumSnapshotForHeight`], whether that is because it + /// never existed or because it has since fallen out of every source's retained + /// window. + async fn snapshot_for(&self, height: Height) -> Result { + if let Some(snapshot) = self.state.lock().await.snapshots.get(&height) { + return Ok(snapshot.clone()); + } + + let candidates = join_all(self.sources.iter().map(|s| s.snapshot_at(height))) + .await + .into_iter() + .filter_map(Result::ok) + .collect(); + + let (agreed_height, trusted) = match self.reach_quorum(candidates) { + Ok(agreed) => agreed, + Err(DirectoryClientError::QuorumNotReached { .. }) => { + return Err(DirectoryClientError::NoQuorumSnapshotForHeight( + height.value(), + )); + } + Err(other) => return Err(other), + }; + if agreed_height != height { + return Err(DirectoryClientError::NoQuorumSnapshotForHeight( + height.value(), + )); + } + + self.state + .lock() + .await + .snapshots + .insert(height, trusted.clone()); + Ok(trusted) + } +} + /// Append `bytes` prefixed with its u32 little-endian length, so adjacent /// variable-length fields cannot be confused with one another. Mirrors /// `nym_directory_contract_common::helpers::push_len_prefixed`'s framing (private to @@ -88,6 +385,9 @@ pub(crate) fn digest_snapshot_signing_payload( #[cfg(test)] mod tests { use super::*; + use nym_crypto::asymmetric::ed25519::{KeyPair, PublicKey}; + use nym_test_utils::helpers::u64_seeded_rng; + use std::collections::HashMap; use std::str::FromStr; fn contract() -> AccountId { @@ -102,6 +402,53 @@ mod tests { AppHash::try_from(vec![byte; 32]).unwrap() } + fn keypair(seed: u64) -> KeyPair { + let mut rng = u64_seeded_rng(seed); + KeyPair::new(&mut rng) + } + + fn signed_snapshot_with( + kp: &KeyPair, + chain_id: &str, + contract: &AccountId, + height: Height, + app_hash: AppHash, + accumulator: LtHash16, + node_identities_hash: [u8; 32], + ) -> SignedDigestSnapshot { + let snapshot = DigestSnapshot { + chain_id: chain::Id::try_from(chain_id).unwrap(), + directory_contract: contract.clone(), + height, + app_hash, + accumulator, + node_identities_hash, + }; + let signature = kp.private_key().sign(snapshot.signing_payload()); + SignedDigestSnapshot { + snapshot, + signer: *kp.public_key(), + signature, + } + } + + fn signed_snapshot( + kp: &KeyPair, + chain_id: &str, + contract: &AccountId, + height: Height, + ) -> SignedDigestSnapshot { + signed_snapshot_with( + kp, + chain_id, + contract, + height, + app_hash(1), + LtHash16::new(), + [0u8; 32], + ) + } + #[test] fn digest_snapshot_payload_is_deterministic_and_field_sensitive() { let contract = contract(); @@ -243,4 +590,382 @@ mod tests { let node_payload = nym_directory_contract_common::node_signing_payload(1, "x", 1, b"y"); assert!(!node_payload.starts_with(DIGEST_SNAPSHOT_DOMAIN_TAG)); } + + #[test] + fn verify_accepts_a_valid_attestation_from_a_trusted_signer() { + let kp = keypair(1); + let trusted = HashSet::from([*kp.public_key()]); + let snapshot = signed_snapshot(&kp, "nyx-testnet", &contract(), Height::from(100u32)); + + assert!(snapshot.verify(&trusted, &"nyx-testnet".parse().unwrap(), &contract())); + } + + #[test] + fn verify_rejects_an_untrusted_signer() { + let kp = keypair(1); + let other = keypair(2); + let trusted = HashSet::from([*other.public_key()]); + let snapshot = signed_snapshot(&kp, "nyx-testnet", &contract(), Height::from(100u32)); + + assert!(!snapshot.verify(&trusted, &"nyx-testnet".parse().unwrap(), &contract())); + } + + #[test] + fn verify_rejects_a_mismatched_chain_id_or_contract() { + let kp = keypair(1); + let trusted = HashSet::from([*kp.public_key()]); + let snapshot = signed_snapshot(&kp, "nyx-testnet", &contract(), Height::from(100u32)); + + assert!(!snapshot.verify(&trusted, &"nyx-mainnet".parse().unwrap(), &contract())); + assert!(!snapshot.verify(&trusted, &"nyx-testnet".parse().unwrap(), &other_contract())); + } + + #[test] + fn verify_rejects_a_forged_or_malformed_signature() { + let kp = keypair(1); + let trusted = HashSet::from([*kp.public_key()]); + + let mut forged = signed_snapshot(&kp, "nyx-testnet", &contract(), Height::from(100u32)); + forged.signature = keypair(2) + .private_key() + .sign(forged.snapshot.signing_payload()); + assert!(!forged.verify(&trusted, &"nyx-testnet".parse().unwrap(), &contract())); + + let mut malformed = signed_snapshot(&kp, "nyx-testnet", &contract(), Height::from(101u32)); + malformed.signature = forged.signature; + assert!(!malformed.verify(&trusted, &"nyx-testnet".parse().unwrap(), &contract())); + } + + fn anchor(trusted: HashSet, quorum: usize) -> AttestedTrustAnchor<()> { + AttestedTrustAnchor::new( + Vec::new(), + trusted, + quorum, + chain::Id::try_from("nyx-testnet").unwrap(), + contract(), + ) + .unwrap() + } + + #[test] + fn new_rejects_zero_quorum() { + let trusted = HashSet::from([*keypair(1).public_key()]); + let result = AttestedTrustAnchor::<()>::new( + Vec::new(), + trusted, + 0, + chain::Id::try_from("nyx-testnet").unwrap(), + contract(), + ); + assert!(matches!( + result, + Err(DirectoryClientError::InvalidQuorumConfig { + quorum: 0, + signers: 1 + }) + )); + } + + #[test] + fn new_rejects_quorum_exceeding_signer_count() { + let trusted = HashSet::from([*keypair(1).public_key()]); + let result = AttestedTrustAnchor::<()>::new( + Vec::new(), + trusted, + 2, + chain::Id::try_from("nyx-testnet").unwrap(), + contract(), + ); + assert!(matches!( + result, + Err(DirectoryClientError::InvalidQuorumConfig { + quorum: 2, + signers: 1 + }) + )); + } + + #[test] + fn new_accepts_a_valid_configuration() { + let trusted = HashSet::from([*keypair(1).public_key(), *keypair(2).public_key()]); + assert!( + AttestedTrustAnchor::<()>::new( + Vec::new(), + trusted, + 2, + chain::Id::try_from("nyx-testnet").unwrap(), + contract(), + ) + .is_ok() + ); + } + + #[test] + fn reach_quorum_accepts_k_distinct_agreeing_signers() { + let a = keypair(1); + let b = keypair(2); + let trusted = HashSet::from([*a.public_key(), *b.public_key()]); + let anchor = anchor(trusted, 2); + + let candidates = vec![ + signed_snapshot(&a, "nyx-testnet", &contract(), Height::from(100u32)), + signed_snapshot(&b, "nyx-testnet", &contract(), Height::from(100u32)), + ]; + + let (height, _) = anchor.reach_quorum(candidates).unwrap(); + assert_eq!(height, Height::from(100u32)); + } + + #[test] + fn reach_quorum_fails_with_fewer_than_k_agreeing_signers() { + let a = keypair(1); + let b = keypair(2); + let trusted = HashSet::from([*a.public_key(), *b.public_key()]); + let anchor = anchor(trusted, 2); + + let candidates = vec![signed_snapshot( + &a, + "nyx-testnet", + &contract(), + Height::from(100u32), + )]; + + let err = anchor.reach_quorum(candidates).unwrap_err(); + assert!(matches!( + err, + DirectoryClientError::QuorumNotReached { + needed: 2, + agreed: 1 + } + )); + } + + #[test] + fn reach_quorum_counts_a_duplicated_signer_once() { + let a = keypair(1); + let b = keypair(2); + let trusted = HashSet::from([*a.public_key(), *b.public_key()]); + let anchor = anchor(trusted, 2); + + // the same signer's attestation presented twice must not, by itself, reach a + // quorum of 2 + let candidates = vec![ + signed_snapshot(&a, "nyx-testnet", &contract(), Height::from(100u32)), + signed_snapshot(&a, "nyx-testnet", &contract(), Height::from(100u32)), + ]; + + let err = anchor.reach_quorum(candidates).unwrap_err(); + assert!(matches!( + err, + DirectoryClientError::QuorumNotReached { + needed: 2, + agreed: 1 + } + )); + } + + #[test] + fn reach_quorum_ignores_untrusted_or_invalid_attestations() { + let a = keypair(1); + let untrusted = keypair(2); + let trusted = HashSet::from([*a.public_key()]); + let anchor = anchor(trusted, 1); + + let mut forged = signed_snapshot(&a, "nyx-testnet", &contract(), Height::from(100u32)); + forged.signature = untrusted + .private_key() + .sign(forged.snapshot.signing_payload()); + + let candidates = vec![ + signed_snapshot(&untrusted, "nyx-testnet", &contract(), Height::from(100u32)), + forged, + ]; + + let err = anchor.reach_quorum(candidates).unwrap_err(); + assert!(matches!( + err, + DirectoryClientError::QuorumNotReached { + needed: 1, + agreed: 0 + } + )); + } + + #[test] + fn reach_quorum_rejects_disagreeing_signers() { + let a = keypair(1); + let b = keypair(2); + let trusted = HashSet::from([*a.public_key(), *b.public_key()]); + let anchor = anchor(trusted, 2); + + // a and b sign DIFFERENT accumulators at the same height - no single value + // reaches the quorum of 2 + let candidates = vec![ + signed_snapshot_with( + &a, + "nyx-testnet", + &contract(), + Height::from(100u32), + app_hash(1), + LtHash16::new(), + [0u8; 32], + ), + signed_snapshot_with( + &b, + "nyx-testnet", + &contract(), + Height::from(100u32), + app_hash(2), + LtHash16::new(), + [0u8; 32], + ), + ]; + + let err = anchor.reach_quorum(candidates).unwrap_err(); + assert!(matches!( + err, + DirectoryClientError::QuorumNotReached { + needed: 2, + agreed: 1 + } + )); + } + + struct MockSource { + identity: ed25519::PublicKey, + latest: Option, + by_height: HashMap, + } + + #[async_trait] + impl AttestationSource for MockSource { + fn identity(&self) -> PublicKey { + self.identity + } + + async fn latest_snapshot(&self) -> Result { + self.latest + .clone() + .ok_or(DirectoryClientError::NoQuorumSnapshotForHeight(0)) + } + + async fn snapshot_at( + &self, + height: Height, + ) -> Result { + self.by_height.get(&height).cloned().ok_or( + DirectoryClientError::NoQuorumSnapshotForHeight(height.value()), + ) + } + } + + fn mock_source(kp: &KeyPair, height: Height) -> MockSource { + let snapshot = signed_snapshot(kp, "nyx-testnet", &contract(), height); + MockSource { + identity: *kp.public_key(), + latest: Some(snapshot.clone()), + by_height: HashMap::from([(height, snapshot)]), + } + } + + #[tokio::test] + async fn refresh_seeds_a_height_and_confirms_it_across_sources() { + let a = keypair(1); + let b = keypair(2); + let trusted = HashSet::from([*a.public_key(), *b.public_key()]); + let sources = vec![ + mock_source(&a, Height::from(100u32)), + mock_source(&b, Height::from(100u32)), + ]; + let anchor = AttestedTrustAnchor::new( + sources, + trusted, + 2, + chain::Id::try_from("nyx-testnet").unwrap(), + contract(), + ) + .unwrap(); + + let height = anchor.refresh().await.unwrap(); + assert_eq!(height, Height::from(100u32)); + assert_eq!(anchor.latest_snapshot_height().await.unwrap(), height); + } + + #[tokio::test] + async fn refresh_fails_when_sources_disagree() { + let a = keypair(1); + let b = keypair(2); + let trusted = HashSet::from([*a.public_key(), *b.public_key()]); + // a and b each only ever answer for their OWN, different height, so asking the + // other for the seeded height always comes back empty + let sources = vec![ + mock_source(&a, Height::from(100u32)), + mock_source(&b, Height::from(200u32)), + ]; + let anchor = AttestedTrustAnchor::new( + sources, + trusted, + 2, + chain::Id::try_from("nyx-testnet").unwrap(), + contract(), + ) + .unwrap(); + + let err = anchor.refresh().await.unwrap_err(); + assert!(matches!( + err, + DirectoryClientError::QuorumNotReached { + needed: 2, + agreed: 1 + } + )); + } + + #[tokio::test] + async fn snapshot_for_returns_the_cached_value_on_a_second_call() { + let a = keypair(1); + let b = keypair(2); + let trusted = HashSet::from([*a.public_key(), *b.public_key()]); + let sources = vec![ + mock_source(&a, Height::from(100u32)), + mock_source(&b, Height::from(100u32)), + ]; + let anchor = AttestedTrustAnchor::new( + sources, + trusted, + 2, + chain::Id::try_from("nyx-testnet").unwrap(), + contract(), + ) + .unwrap(); + + let first = anchor.snapshot_for(Height::from(100u32)).await.unwrap(); + let second = anchor.snapshot_for(Height::from(100u32)).await.unwrap(); + assert_eq!(first.accumulator, second.accumulator); + } + + #[tokio::test] + async fn snapshot_for_rejects_a_height_no_quorum_can_attest() { + let a = keypair(1); + let b = keypair(2); + let trusted = HashSet::from([*a.public_key(), *b.public_key()]); + let sources = vec![ + mock_source(&a, Height::from(100u32)), + mock_source(&b, Height::from(100u32)), + ]; + let anchor = AttestedTrustAnchor::new( + sources, + trusted, + 2, + chain::Id::try_from("nyx-testnet").unwrap(), + contract(), + ) + .unwrap(); + + let err = anchor.snapshot_for(Height::from(999u32)).await.unwrap_err(); + assert!(matches!( + err, + DirectoryClientError::NoQuorumSnapshotForHeight(999) + )); + } } diff --git a/common/nym-directory-client/src/error.rs b/common/nym-directory-client/src/error.rs index 1fc317c831d..ee5a6312d13 100644 --- a/common/nym-directory-client/src/error.rs +++ b/common/nym-directory-client/src/error.rs @@ -78,4 +78,23 @@ pub enum DirectoryClientError { #[error("non-canonical commit returned for height {0}")] NonCanonicalCommit(u64), + + /// Fewer than `needed` distinct trusted signers agreed on identical attested + /// values (or none did). `agreed` is the largest distinct-signer count seen across + /// any single value grouping, so callers can see how close the quorum came. + #[error("quorum not reached: needed {needed} distinct trusted signers, got {agreed}")] + QuorumNotReached { needed: usize, agreed: usize }, + + /// No quorum-agreed attestation exists for the requested height. This can be + /// transient (a source has not yet, or no longer, holds that height) or permanent + /// (the height was never a real snapshot point) - the anchor cannot always tell + /// which, since a requested height only ever comes from a real observed snapshot + /// (self-seeded during `refresh`, or externally supplied by a caller with + /// independent reason to trust it exists), never guessed. + #[error("no quorum-agreed snapshot exists for height {0}")] + NoQuorumSnapshotForHeight(u64), + + /// `AttestedTrustAnchor::new` was called with a degenerate quorum threshold. + #[error("invalid quorum configuration: quorum {quorum} with {signers} trusted signers")] + InvalidQuorumConfig { quorum: usize, signers: usize }, } diff --git a/openspec/changes/directory-attested-anchor/design.md b/openspec/changes/directory-attested-anchor/design.md index efbd438f655..e95871940ee 100644 --- a/openspec/changes/directory-attested-anchor/design.md +++ b/openspec/changes/directory-attested-anchor/design.md @@ -57,19 +57,29 @@ Revisited mid-implementation. `node_signing_payload` lives in `nym-directory-con - `digest_snapshot_signing_payload(chain_id: &str, contract: &AccountId, height: Height, app_hash: &AppHash, accumulator: &LtHash16, node_identities_hash: &[u8; 32]) -> Vec` in `src/anchor/attested.rs`, `pub(crate)` since nothing outside this crate consumes it yet. Uses the crate's own real types (`AccountId`, `LtHash16`, `AppHash`) rather than primitive-typed workarounds, since this crate already depends on `cosmrs` / `nym-lthash` / `nym-crypto`. Has its own small local length-prefixing helper (mirroring, not reusing, `nym_directory_contract_common::helpers::push_len_prefixed`, which is private to that crate) and a distinct domain-separation tag so a snapshot signature can never be confused with a node-entry signature. - `node_identities_hash` in `src/verify.rs`, next to `recompute_accumulator` - takes `(NodeId, ed25519::PublicKey)` pairs directly (this crate already depends on `nym-crypto`), sorts internally, and hashes with `blake3` (a new direct dependency of this crate; already transitively present via `nym-lthash`). +`DigestSnapshot` additionally derives `PartialEq, Eq` and a manual `Hash` (added during task 3, to key a `HashMap` in `reach_quorum` - see D6). `Hash` is manual, not derived, because two of its fields need help: `directory_contract: AccountId` and `app_hash: AppHash` hash via `.as_ref()` (their string/byte views) rather than a direct `Hash` impl, since neither type derives one; `accumulator: LtHash16` hashes directly, via a new unconditional `impl Hash for LtHash` added to `nym-lthash` (hashing the underlying `[u16; ELEMENTS]` state) - unconditional and no new dependency, unlike the `serde` feature work, since `core::hash::Hash` needs nothing extra. + This intentionally does *not* try to pre-build a neutral shared-crate home for a producer that doesn't exist yet and isn't scoped in this change (see the deferred nym-api producer endpoint in Non-Goals). `nym-api` already depends on multiple `-client` crates for shared chain logic (e.g. `nym-validator-client`), so a future producer depending on `nym-directory-client` - or extracting a shared piece once its real constraints are known - is a reasonable question to leave to that follow-up rather than deciding now. ### D4: Sybil resistance via a configured signer set and a quorum threshold -The anchor holds `trusted_signers: BTreeSet` and `quorum: usize`, seeded either from the caller's own configuration or from the shipped default (D8). An individual attestation is valid iff its signer is in the trusted set AND its signature verifies over the canonical payload AND its chain-id and contract match the configured ones. A snapshot is trusted iff at least K DISTINCT trusted signers produce valid attestations over identical `(height, app_hash, accumulator, node_identities_hash)`. Distinctness is by signer key, so a single malicious or duplicated source cannot inflate the count. K and N are validated at construction (`1 <= K <= N`). +The anchor holds `trusted_signers: HashSet` (`HashSet`, not the originally-sketched `BTreeSet` - `PublicKey` derives `Hash` but not `Ord`, and a signer allowlist has no ordering requirement to justify adding one) and `quorum: usize`, seeded either from the caller's own configuration or from the shipped default (D8). An individual attestation is valid iff its signer is in the trusted set AND its signature verifies over the canonical payload AND its chain-id and contract match the configured ones. A snapshot is trusted iff at least K DISTINCT trusted signers produce valid attestations over identical `(height, app_hash, accumulator, node_identities_hash)`. Distinctness is by signer key, so a single malicious or duplicated source cannot inflate the count. K and N are validated at construction (`1 <= K <= N`). ### D5: `AttestationSource` transport trait; concrete HTTP deferred -Unchanged from the original sketch: `#[async_trait] trait AttestationSource { async fn latest_snapshot(&self) -> Result; async fn snapshot_at(&self, height: Height) -> Result; }`. The anchor holds `Vec` and queries them concurrently. The concrete HTTP transport is deferred to the producer change. No new Cargo feature gate needed (only `nym-crypto`, `async-trait`, `serde`). +Gained one method beyond the original sketch: `#[async_trait] trait AttestationSource { fn identity(&self) -> ed25519::PublicKey; async fn latest_snapshot(&self) -> Result; async fn snapshot_at(&self, height: Height) -> Result; }`. `identity()` (sync, no network call) lets the anchor recognize which configured source produced a given attestation - used in `refresh()` (D6) to skip re-querying the seed's own source for confirmation, since its attestation is already in hand. The concrete HTTP transport is still deferred to the producer change. No new Cargo feature gate needed (only `nym-crypto`, `async-trait`, `serde`). + +### D6: Height model - a seeded height confirmed by the rest of the quorum, plus a retained window for known heights + +Revised mid-implementation. The original sketch had `refresh()` query every source's own `latest_snapshot()` and reach quorum over those independent answers - but nym-apis are not perfectly in lockstep, so right at a cadence boundary that can split honest sources across two heights (some already past it, some not yet there), starving quorum at either even though enough sources are behaving correctly. + +`refresh()` instead: try sources' `latest_snapshot()` **in shuffled order, one at a time**, and take the first successful response as a height *seed* `H` (refined again mid-implementation - see the open concern below). The seed is untrusted at this point - just a discovery hint, not a trust decision - so a lying or malicious seed only wastes a round-trip (nothing else will agree on a fabricated height); it can never cause a false accept, since acceptance still requires K distinct trusted signers agreeing on identical `(height, app_hash, accumulator, node_identities_hash)` regardless of where the hint came from. `refresh()` then queries every *other* source's `snapshot_at(H)` concurrently - identified via `identity()` (D5), so the seed's own source is not asked again for something it already answered - and runs `reach_quorum` over the seed plus all of those responses. Pinning to one concrete, already-observed height and asking everyone specifically for it is an apples-to-apples comparison regardless of who has raced ahead. + +For a specific height `H` a caller already has independent reason to believe is real (not seeded via `refresh()`), `snapshot_for(H)` queries all sources' `snapshot_at(H)` directly and runs the same `reach_quorum`, verifying the agreed height actually equals `H` (a source could otherwise return an honestly-signed attestation for the *wrong* height). Because `H` only ever comes from a real observed snapshot - either self-seeded during `refresh()`, or externally supplied by a caller with independent reason to trust it exists - a height the quorum cannot confirm has one coherent meaning, `NoQuorumSnapshotForHeight(H)`, rather than needing a second variant for "this height can structurally never exist" (see Risks). -### D6: Height model - latest quorum snapshot plus a retained window +Verified snapshots are cached by height in a `BTreeMap` behind a `Mutex` (the `Mutex` only exists to expose `&self`; there is no real concurrency need). `reach_quorum` accepts the *first* group (in `candidates`' own order) to reach `quorum`, grouped via a `HashMap>` (not the linear scan originally planned - `DigestSnapshot` gained a manual `Hash` impl, see D3, once `LtHash16` had one to build it from). Determinism of "first to reach quorum" does not depend on `HashMap` iteration order: candidates are still visited in `candidates`' own (deterministic) order, and the `HashMap` is only used as an O(1) lookup for "which group does this candidate's exact value belong to," not iterated over to pick a winner. The one place iteration order would show up - the fallback `agreed` count on failure - takes a `max()` over group sizes, which is order-independent. -Unchanged: `refresh()` / `latest_snapshot_height()` fetch each source's latest signed snapshot, agree a common value across the quorum, cache it by height in a `BTreeMap` behind a `Mutex`. A miss on a specific height fetches `snapshot_at(H)`; a height the quorum cannot attest returns an error. +**Open concern (unresolved as of this revision):** trying sources sequentially for `latest_snapshot()` trades away the concurrent version's latency bound. If sources early in the shuffled order are down or slow, `first_latest_snapshot` waits out each one's full timeout in turn before trying the next - worst case, one timeout period *per dead source*, rather than the one-timeout-period bound a concurrent race gives regardless of how many sources are down. The randomization's goal (not always seeding from the same source) does not strictly need sequential querying to achieve - `join_all` over a shuffled source list, taking the first `Ok` from the results, would get the same distribution without the timeout-stacking risk. Flagging rather than changing, since there may be a reason (e.g. minimizing request volume against a "just a hint" query) that outweighs the latency cost. ### D7: Freshness by height, not by a baked-in expiry field @@ -88,7 +98,7 @@ The existing `recompute_accumulator` check in `verified_directory` is already so ## Risks / Trade-offs - [Quorum collusion] K colluding, trusted signers can attest a false snapshot. Mitigation: choose independently operated nym-apis and set K high relative to N; the whole-directory recompute still fails closed if the attested hashes do not match the served data. Residual: a fully colluding quorum could serve a self-consistent fake directory *and* fake identity bindings. This is the explicit trust assumption of attested mode - strictly weaker than the light client, strictly stronger than a single trusted RPC. -- [Liveness at cadence boundaries] If fewer than K sources are reachable, or the quorum straddles a snapshot boundary and disagrees on the latest height, `refresh()` fails. Mitigation: the retained window lets the client fall back to a slightly older agreed height; `QuorumNotReached { needed, agreed }` surfaces the shortfall clearly. +- [Liveness at cadence boundaries] The seed-then-confirm `refresh()` flow (D6) already removes most of the risk of independent per-source "latest" queries splitting across a cadence boundary. Residual: if fewer than K sources currently hold the seeded height (some have not produced it yet, others have already aged it out), `refresh()` still fails. Mitigation: `QuorumNotReached { needed, agreed }` / `NoQuorumSnapshotForHeight` surface the shortfall clearly, and a caller can simply retry - a later attempt will likely seed a height more sources currently hold. - [Default-anchor staleness] The shipped default is compiled in; if Nym SA ever needs to rotate those specific keys, every deployment relying on the default needs a new crate release to pick up the change. Mitigation: keep the default small (fewer keys to ever need rotating) and document the override path prominently so operators with stricter needs are not depending on it in the first place. This is the same staleness shape as the light-client checkpoint's trust period, just far rarer and smaller in blast radius. - [Signer-set churn] Rotating a nym-api key (beyond the default) requires reconfiguring clients. Mitigation: the set is config-supplied, not hardcoded, for callers who override it; a future upgrade (see "Future direction") can derive the live set from chain state instead. - [Format churn when the producer lands] Mitigation: the canonical signing payload is defined now in the shared crate, and the producer follow-up reuses it verbatim. diff --git a/openspec/changes/directory-attested-anchor/tasks.md b/openspec/changes/directory-attested-anchor/tasks.md index ef3bc228dcb..7a6191bcc54 100644 --- a/openspec/changes/directory-attested-anchor/tasks.md +++ b/openspec/changes/directory-attested-anchor/tasks.md @@ -15,19 +15,31 @@ shared piece, once its real constraints are known. ## 2. Attestation types and transport trait (client crate) -- [ ] 2.1 Define `DigestSnapshot { chain_id: String, directory_contract: AccountId (or String), height: Height, app_hash: AppHash, accumulator: [u8; DIGEST_LEN], node_identities_hash: [u8; 32] }` and `SignedDigestSnapshot { snapshot: DigestSnapshot, signer: ed25519::PublicKey, signature: Vec }` in `src/anchor/attested.rs` (Serialize/Deserialize; signature kept as bytes so malformed data is a verification failure, not a decode panic - mirrors `DirectoryNodeEntry`) -- [ ] 2.2 Implement `SignedDigestSnapshot::verify(&self, trusted: &BTreeSet, chain_id: &str, contract: &AccountId) -> bool`: signer in trusted set AND chain-id + contract match AND ed25519 signature verifies over `digest_snapshot_signing_payload(..)` (mirror `node_signature_verifies`) -- [ ] 2.3 Define `#[async_trait] pub trait AttestationSource { async fn latest_snapshot(&self) -> Result; async fn snapshot_at(&self, height: Height) -> Result; }` +`trusted_signers`/`trusted` use `HashSet`, not the originally-sketched +`BTreeSet`: `ed25519::PublicKey` derives `Hash` but not `Ord` (confirmed in +`common/crypto/src/asymmetric/ed25519/mod.rs`), and a signer allowlist has no ordering +requirement to justify adding one - membership testing is all quorum counting needs. + +- [x] 2.1 Define `DigestSnapshot { chain_id: chain::Id, directory_contract: AccountId, height: Height, app_hash: AppHash, accumulator: LtHash16, node_identities_hash: [u8; 32] }` (richer types than the original sketch - all confirmed to have serde support: `chain::Id`/`AccountId`/`Height` via hand-written impls, `AppHash` via `cosmrs::tendermint::serializers::apphash`, `LtHash16` via a new `serde` feature on `nym-lthash`, see task 1's follow-up) and `SignedDigestSnapshot { snapshot: DigestSnapshot, signer: ed25519::PublicKey, signature: Vec }` in `src/anchor/attested.rs` (both `Serialize`/`Deserialize`; signature kept as bytes so malformed data is a verification failure, not a decode panic - mirrors `DirectoryNodeEntry`) +- [x] 2.2 Implement `SignedDigestSnapshot::verify(&self, trusted: &HashSet, chain_id: &str, contract: &AccountId) -> bool`: signer in trusted set AND chain-id + contract match AND ed25519 signature verifies over `digest_snapshot_signing_payload(..)` (mirror `node_signature_verifies`); unit tests: valid attestation accepted, untrusted signer / mismatched chain-id / mismatched contract / forged or malformed signature all rejected +- [x] 2.3 Define `#[async_trait] pub trait AttestationSource { fn identity(&self) -> ed25519::PublicKey; async fn latest_snapshot(&self) -> Result; async fn snapshot_at(&self, height: Height) -> Result; }` (`identity()` added mid-implementation, ahead of the original sketch - a sync, no-network way for the anchor to recognize which source produced a given attestation, used by `refresh()`, task 3.5, to avoid re-querying the seed's own source) ## 3. AttestedTrustAnchor core -- [ ] 3.1 Define `TrustedSnapshot { app_hash: AppHash, accumulator: LtHash16, node_identities_hash: [u8; 32] }` and private `AttestedTrustAnchorState { snapshots: BTreeMap, latest: Option }` -- [ ] 3.2 Define `AttestedTrustAnchor { sources: Vec, trusted_signers: BTreeSet, quorum: usize, chain_id: String, directory_contract: AccountId, state: Mutex }` -- [ ] 3.3 Implement `new(sources, trusted_signers, quorum, chain_id, directory_contract)` validating `1 <= quorum <= trusted_signers.len()` (error otherwise) -- [ ] 3.4 Implement a private `reach_quorum(candidates: Vec) -> Result<(Height, TrustedSnapshot), DirectoryClientError>`: filter to valid attestations (via `verify`), group by `(height, app_hash, accumulator, node_identities_hash)`, count DISTINCT signer keys per group, accept the first group reaching `quorum`, else `QuorumNotReached { needed, agreed }` -- [ ] 3.5 Implement `refresh(&self) -> Result`: query all sources' `latest_snapshot()` concurrently, `reach_quorum`, insert into `snapshots`, set `latest`, return the agreed height -- [ ] 3.6 Implement `latest_snapshot_height(&self) -> Result`: return cached `latest` or call `refresh()` -- [ ] 3.7 Implement a private `snapshot_for(&self, height) -> Result`: cache hit on `height` returns immediately; on miss query all sources' `snapshot_at(height)`, `reach_quorum` (verifying the returned height matches), cache, return; a height the quorum cannot attest returns `NoQuorumSnapshotForHeight(height)` +`refresh()`'s flow was revised mid-implementation (see `design.md` D6): rather than +comparing every source's independently-reported "latest" (which can split across a +cadence boundary if sources are not perfectly in lockstep), it seeds a height from the +first successful `latest_snapshot()` response, then asks every source's `snapshot_at` +that same height and reaches quorum over all of it. The seed is untrusted at that point - +just a discovery hint - so a lying seed only wastes a round-trip, never a false accept. + +- [x] 3.1 Define `TrustedSnapshot { app_hash: AppHash, accumulator: LtHash16, node_identities_hash: [u8; 32] }` and private `AttestedTrustAnchorState { snapshots: BTreeMap, latest: Option }` +- [x] 3.2 Define `AttestedTrustAnchor { sources: Vec, trusted_signers: HashSet, quorum: usize, chain_id: chain::Id, directory_contract: AccountId, state: Mutex }` (`chain_id` typed as `chain::Id`, matching `DigestSnapshot` and `verify`, not the originally-sketched `String`) +- [x] 3.3 Implement `new(sources, trusted_signers, quorum, chain_id, directory_contract)` validating `1 <= quorum <= trusted_signers.len()` (error otherwise) +- [x] 3.4 Implement a private `reach_quorum(&self, candidates: Vec) -> Result<(Height, TrustedSnapshot), DirectoryClientError>`: filter to valid attestations (via `verify`), group by `(height, app_hash, accumulator, node_identities_hash)` via a `HashMap>` keyed on a manual `Hash` impl added to `DigestSnapshot` (not the linear scan originally planned, once `LtHash16` gained a `Hash` impl to build it from - see design.md D3/D6), count DISTINCT signer keys per group, accept the *first* group (in candidate-arrival order, checked as each candidate is folded in, so the result is deterministic regardless of `HashMap` iteration order) reaching `quorum`, else `QuorumNotReached { needed, agreed }` (`agreed` = the largest distinct-signer count seen across any single group, via an order-independent `max()`) +- [x] 3.5 Implement `refresh(&self) -> Result`: try sources' `latest_snapshot()` in shuffled order, one at a time, taking the first successful response as a height seed `H` (see design.md D6 for an open latency concern with this vs. querying concurrently); query every *other* source's (via `identity()`, task 2.3) `snapshot_at(H)` concurrently; `reach_quorum` over the seed plus all of those responses; insert into `snapshots`, set `latest`, return the agreed height +- [x] 3.6 Implement `latest_snapshot_height(&self) -> Result`: return cached `latest` or call `refresh()` +- [x] 3.7 Implement a private `snapshot_for(&self, height) -> Result`: cache hit on `height` returns immediately; on miss query all sources' `snapshot_at(height)`, `reach_quorum` (verifying the returned height equals the requested one, else `NoQuorumSnapshotForHeight(height)` even if some other height reached quorum), cache, return; a height the quorum cannot attest at all returns `NoQuorumSnapshotForHeight(height)` ## 4. Default anchor @@ -51,11 +63,11 @@ shared piece, once its real constraints are known. ## 7. Error handling -- [ ] 7.1 Add to `DirectoryClientError`: `QuorumNotReached { needed: usize, agreed: usize }`, `NoQuorumSnapshotForHeight(u64)`, `InvalidQuorumConfig { quorum: usize, signers: usize }`, `NodeIdentitiesHashUnavailable`, and an attestation-transport / decode variant as needed +- [ ] 7.1 Add to `DirectoryClientError`: ~~`QuorumNotReached { needed: usize, agreed: usize }`~~, ~~`NoQuorumSnapshotForHeight(u64)`~~, ~~`InvalidQuorumConfig { quorum: usize, signers: usize }`~~ (all three added early, needed by task 3), `NodeIdentitiesHashUnavailable`, and an attestation-transport / decode variant as needed ## 8. Tests (mock transport) -- [ ] 8.1 Add a `MockAttestationSource` (in-memory, serves pre-registered latest + per-height signed snapshots; records call log) - mirror the `MockRpcClient` pattern; helper to build a `SignedDigestSnapshot` from a seeded `KeyPair` +- [ ] 8.1 Add a `MockAttestationSource` (in-memory, serves pre-registered latest + per-height signed snapshots + its `identity()`; records call log) - mirror the `MockRpcClient` pattern; helper to build a `SignedDigestSnapshot` from a seeded `KeyPair`. A non-call-logging version of this already exists as `MockSource` in `attested.rs`'s own test module (task 3); task 8 can promote/extend it rather than starting fresh - [ ] 8.2 Unit test: K distinct trusted signers agreeing on identical values yields a trusted snapshot; `trusted_app_hash` and `trusted_digest` return the attested values - [ ] 8.3 Unit test: fewer than K valid agreeing signers returns `QuorumNotReached` - [ ] 8.4 Unit test: a duplicated signer key is counted once (does not reach quorum on its own) From 2a0739e611c0be68ca4164986e3855a9036621ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C4=99drzej=20Stuczy=C5=84ski?= Date: Wed, 8 Jul 2026 14:52:25 +0100 Subject: [PATCH 4/6] define attestation source anchors --- Cargo.lock | 1 + common/network-defaults/src/mainnet.rs | 47 ++++++++--- common/network-defaults/src/network.rs | 59 ++++++++++++- common/network-defaults/src/var_names.rs | 1 + common/nym-directory-client/Cargo.toml | 1 + .../src/anchor/attested.rs | 84 +++++++++++++++++++ .../directory-attested-anchor/design.md | 16 +++- .../directory-attested-anchor/tasks.md | 14 +++- 8 files changed, 200 insertions(+), 23 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8873b19d196..74f6ae4e797 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6860,6 +6860,7 @@ dependencies = [ "nym-directory-contract-common", "nym-lthash", "nym-mixnet-contract-common", + "nym-network-defaults", "nym-test-utils", "nym-validator-client", "prost 0.13.5", diff --git a/common/network-defaults/src/mainnet.rs b/common/network-defaults/src/mainnet.rs index 24fa18ad114..dc4fc3b1980 100644 --- a/common/network-defaults/src/mainnet.rs +++ b/common/network-defaults/src/mainnet.rs @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 #[cfg(feature = "network")] -use crate::{ApiUrlConst, DenomDetails, ValidatorDetails}; +use crate::{ApiUrlConst, DenomDetails, DirectoryAttestationSourceConst, ValidatorDetails}; pub const NETWORK_NAME: &str = "mainnet"; @@ -73,6 +73,25 @@ pub const UPGRADE_MODE_ATTESTATION_URL: &str = pub const UPGRADE_MODE_ATTESTER_ED25519_BS58_PUBKEY: &str = "3bgffBYcfFkTTXc2npNNn9MkddFZ3H2LrPjXDmnJzrqd"; +#[cfg(feature = "network")] +pub const DIRECTORY_ATTESTATION_SOURCES: &[DirectoryAttestationSourceConst] = &[ + DirectoryAttestationSourceConst { + api_url: "https://nym-api-signer-1.nymtech.net/api", + identity_ed25519_bs58: "5dRq2oCSD6GUozZH3Qq5p1hPuztT6sFvpxtSbK311tDp", + }, + DirectoryAttestationSourceConst { + api_url: "https://nym-api-signer-2.nymtech.net/api", + identity_ed25519_bs58: "2dJHDhUr5bRWxELMzZBZGVAW8cUHEJC1mGQYniKscQMo", + }, + // TODO: we need unconditional key exposure for this to work + // (currently https://validator.nymtech.net/api even though has an ed25519 key it does NOT expose + // it anywhere) + // DirectoryAttestationSourceConst { + // api_url: "https://validator.nymtech.net/api", + // identity_ed25519_bs58: "", + // }, +]; + #[cfg(feature = "network")] pub const NYM_VPN_APIS: &[ApiUrlConst] = &[ ApiUrlConst { @@ -85,14 +104,6 @@ pub const NYM_VPN_APIS: &[ApiUrlConst] = &[ }, ]; -#[cfg(feature = "env")] -fn serialize_api_urls(urls: &[ApiUrlConst]) -> String { - serde_json::to_string(urls) - .inspect_err(|e| tracing::warn!("failed to serialize nym_api_urls for env: {e}")) - .unwrap_or_default() -} - -// I'm making clippy mad on purpose, because that url HAS TO be updated and deployed before merging pub const EXIT_POLICY_URL: &str = "https://nymtech.net/.wellknown/network-requester/exit-policy.txt"; @@ -153,6 +164,7 @@ pub fn read_parsed_var(var: &str) -> Result { #[cfg(all(feature = "env", feature = "network"))] pub fn export_to_env() { + use crate::network::json_serialise; use crate::var_names; set_var_to_default(var_names::CONFIGURED, "true"); @@ -203,11 +215,11 @@ pub fn export_to_env() { ); set_var_to_default(var_names::NYXD, NYXD_URL); set_var_to_default(var_names::NYM_API, NYM_API); - set_var_to_default(var_names::NYM_APIS, &serialize_api_urls(NYM_APIS)); + set_var_to_default(var_names::NYM_APIS, &json_serialise(NYM_APIS)); set_var_to_default(var_names::NYXD_WEBSOCKET, NYXD_WS); set_var_to_default(var_names::EXIT_POLICY_URL, EXIT_POLICY_URL); set_var_to_default(var_names::NYM_VPN_API, NYM_VPN_API); - set_var_to_default(var_names::NYM_VPN_APIS, &serialize_api_urls(NYM_VPN_APIS)); + set_var_to_default(var_names::NYM_VPN_APIS, &json_serialise(NYM_VPN_APIS)); set_var_to_default( var_names::UPGRADE_MODE_ATTESTATION_URL, UPGRADE_MODE_ATTESTATION_URL, @@ -218,10 +230,15 @@ pub fn export_to_env() { ); set_var_to_default(var_names::NYXD_QUERY_LITE, NYXD_QUERY_LITE); set_var_to_default(var_names::NYXD_WS_LITE, NYXD_WS_LITE); + set_var_to_default( + var_names::DIRECTORY_ATTESTATION_SOURCES, + &json_serialise(DIRECTORY_ATTESTATION_SOURCES), + ); } #[cfg(all(feature = "env", feature = "network"))] pub fn export_to_env_if_not_set() { + use crate::network::json_serialise; use crate::var_names; set_var_conditionally_to_default(var_names::CONFIGURED, "true"); @@ -264,9 +281,9 @@ pub fn export_to_env_if_not_set() { ); set_var_conditionally_to_default(var_names::NYXD, NYXD_URL); set_var_conditionally_to_default(var_names::NYM_API, NYM_API); - set_var_conditionally_to_default(var_names::NYM_APIS, &serialize_api_urls(NYM_APIS)); + set_var_conditionally_to_default(var_names::NYM_APIS, &json_serialise(NYM_APIS)); set_var_conditionally_to_default(var_names::NYM_VPN_API, NYM_VPN_API); - set_var_conditionally_to_default(var_names::NYM_VPN_APIS, &serialize_api_urls(NYM_VPN_APIS)); + set_var_conditionally_to_default(var_names::NYM_VPN_APIS, &json_serialise(NYM_VPN_APIS)); set_var_conditionally_to_default(var_names::NYXD_WEBSOCKET, NYXD_WS); set_var_conditionally_to_default(var_names::EXIT_POLICY_URL, EXIT_POLICY_URL); set_var_conditionally_to_default( @@ -279,4 +296,8 @@ pub fn export_to_env_if_not_set() { ); set_var_conditionally_to_default(var_names::NYXD_QUERY_LITE, NYXD_QUERY_LITE); set_var_conditionally_to_default(var_names::NYXD_WS_LITE, NYXD_WS_LITE); + set_var_conditionally_to_default( + var_names::DIRECTORY_ATTESTATION_SOURCES, + &json_serialise(DIRECTORY_ATTESTATION_SOURCES), + ); } diff --git a/common/network-defaults/src/network.rs b/common/network-defaults/src/network.rs index f4c144c05eb..13bfbdf1e86 100644 --- a/common/network-defaults/src/network.rs +++ b/common/network-defaults/src/network.rs @@ -93,6 +93,44 @@ pub struct ApiUrlConst<'a> { pub front_hosts: Option<&'a [&'a str]>, } +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct DirectoryAttestationSource { + pub api_url: String, + pub identity_ed25519_bs58: String, +} + +/// A nym-api capable of producing a signed directory-digest-snapshot attestation (see +/// `nym-directory-client`'s `AttestedTrustAnchor`), paired with the ed25519 identity +/// key it is expected to sign with. +#[derive(Copy, Clone, Debug, Serialize)] +pub struct DirectoryAttestationSourceConst<'a> { + pub api_url: &'a str, + pub identity_ed25519_bs58: &'a str, +} + +impl From> for DirectoryAttestationSource { + fn from(value: DirectoryAttestationSourceConst) -> Self { + DirectoryAttestationSource { + api_url: value.api_url.into(), + identity_ed25519_bs58: value.identity_ed25519_bs58.into(), + } + } +} + +pub fn default_directory_attestation_sources() -> Vec { + #[cfg(feature = "env")] + { + if crate::env_configured() { + return json_deserialise_env(var_names::DIRECTORY_ATTESTATION_SOURCES) + .unwrap_or_default(); + } + } + mainnet::DIRECTORY_ATTESTATION_SOURCES + .iter() + .map(|i| (*i).into()) + .collect() +} + impl From> for ApiUrl { fn from(value: ApiUrlConst) -> Self { ApiUrl { @@ -153,11 +191,11 @@ impl NymNetworkDetails { } let nym_api = var(var_names::NYM_API).expect("nym api not set"); - let nym_api_urls = try_parse_api_urls(var_names::NYM_APIS).unwrap_or(vec![ApiUrl { + let nym_api_urls = json_deserialise_env(var_names::NYM_APIS).unwrap_or(vec![ApiUrl { url: nym_api.clone(), front_hosts: None, }]); - let nym_vpn_api_urls = try_parse_api_urls(var_names::NYM_VPN_APIS); + let nym_vpn_api_urls = json_deserialise_env(var_names::NYM_VPN_APIS); NymNetworkDetails::new_empty() .with_network_name(var(var_names::NETWORK_NAME).expect("network name not set")) @@ -465,10 +503,23 @@ impl NymNetworkDetails { } #[cfg(feature = "env")] -fn try_parse_api_urls(k: impl AsRef) -> Option> { +pub(crate) fn json_serialise(data: &T) -> String +where + T: ?Sized + serde::Serialize, +{ + serde_json::to_string(data) + .inspect_err(|e| tracing::warn!("failed to serialise data for env: {e}")) + .unwrap_or_default() +} + +#[cfg(feature = "env")] +pub(crate) fn json_deserialise_env(k: impl AsRef) -> Option +where + T: serde::de::DeserializeOwned, +{ let raw = var(k).ok()?; serde_json::from_str(&raw) - .inspect_err(|e| tracing::warn!("failed to parse api urls from env \"{raw:?}\": {e}")) + .inspect_err(|e| tracing::warn!("failed to parse data from env \"{raw:?}\": {e}")) .ok() } diff --git a/common/network-defaults/src/var_names.rs b/common/network-defaults/src/var_names.rs index 5473906de53..42e1df8bc96 100644 --- a/common/network-defaults/src/var_names.rs +++ b/common/network-defaults/src/var_names.rs @@ -38,6 +38,7 @@ pub const NYM_VPN_APIS: &str = "NYM_VPN_APIS"; pub const CLIENT_STATS_COLLECTION_PROVIDER: &str = "CLIENT_STATS_COLLECTION_PROVIDER"; pub const UPGRADE_MODE_ATTESTATION_URL: &str = "UPGRADE_MODE_ATTESTATION_URL"; pub const UPGRADE_MODE_ATTESTER_ED25519_BS58_PUBKEY: &str = "UPGRADE_MODE_ATTESTER_ED25519_PUBKEY"; +pub const DIRECTORY_ATTESTATION_SOURCES: &str = "DIRECTORY_ATTESTATION_SOURCES"; pub const DKG_TIME_CONFIGURATION: &str = "DKG_TIME_CONFIGURATION"; diff --git a/common/nym-directory-client/Cargo.toml b/common/nym-directory-client/Cargo.toml index 57dfc47945b..a26a1adbcfe 100644 --- a/common/nym-directory-client/Cargo.toml +++ b/common/nym-directory-client/Cargo.toml @@ -28,6 +28,7 @@ tendermint-light-client = { workspace = true, optional = true } nym-lthash = { workspace = true, features = ["serde"] } nym-crypto = { workspace = true, features = ["asymmetric"] } +nym-network-defaults = { workspace = true } nym-validator-client = { workspace = true, features = ["http-client"] } nym-directory-contract-common = { workspace = true } nym-mixnet-contract-common = { workspace = true } diff --git a/common/nym-directory-client/src/anchor/attested.rs b/common/nym-directory-client/src/anchor/attested.rs index d69d9cda5ce..e37daae847a 100644 --- a/common/nym-directory-client/src/anchor/attested.rs +++ b/common/nym-directory-client/src/anchor/attested.rs @@ -8,6 +8,7 @@ use cosmrs::tendermint::chain; use futures::future::join_all; use nym_crypto::asymmetric::ed25519; use nym_lthash::LtHash16; +use nym_network_defaults::default_directory_attestation_sources; use nym_validator_client::nyxd::Height; use nym_validator_client::nyxd::hash::AppHash; use rand::seq::SliceRandom; @@ -147,6 +148,18 @@ struct AttestedTrustAnchorState { latest: Option, } +/// Parses identity keys of attestation sources set in the env into the anchor's native representation. +#[allow(clippy::expect_used)] +fn default_trusted_signers() -> HashSet { + default_directory_attestation_sources() + .iter() + .map(|source| { + ed25519::PublicKey::from_base58_string(&source.identity_ed25519_bs58) + .expect("compiled-in default trusted signer key must be valid") + }) + .collect() +} + /// A [`DirectoryTrustAnchor`](crate::anchor::DirectoryTrustAnchor) backed by a K-of-N /// quorum of nym-api identity keys signing directory snapshots, rather than a root key /// or a light-client checkpoint. @@ -193,6 +206,36 @@ impl AttestedTrustAnchor { }) } + /// A simple majority (more than half) of `signer_count` - the quorum policy used + /// by [`Self::with_default_anchor`]. Expressing the default quorum as a function + /// of the signer set's size, rather than a separately hardcoded number, means + /// growing that set (e.g. mainnet's third nym-api gaining a key) automatically + /// moves the default from 2-of-2 to 2-of-3 with no code change anywhere. + pub fn majority_quorum(signer_count: usize) -> usize { + signer_count / 2 + 1 + } + + /// Constructs the anchor using the compiled-in default trust root - + /// [`nym_network_defaults::mainnet::DIRECTORY_ATTESTATION_SOURCES`]' identity keys, + /// requiring [`Self::majority_quorum`] of them to agree. This is the common case, + /// since most deployments have no reason to distrust Nym SA's own instances; + /// callers who do, or who are not on mainnet, should use [`Self::new`] directly. + pub fn with_default_anchor( + sources: Vec, + chain_id: chain::Id, + directory_contract: AccountId, + ) -> Result { + let trusted_signers = default_trusted_signers(); + let quorum = Self::majority_quorum(trusted_signers.len()); + Self::new( + sources, + trusted_signers, + quorum, + chain_id, + directory_contract, + ) + } + /// Filters `candidates` to valid attestations (see /// [`SignedDigestSnapshot::verify`]), groups the survivors by /// `(height, app_hash, accumulator, node_identities_hash)`, and accepts the *first* @@ -700,6 +743,47 @@ mod tests { ); } + #[test] + fn majority_quorum_is_more_than_half() { + assert_eq!(AttestedTrustAnchor::<()>::majority_quorum(1), 1); + assert_eq!(AttestedTrustAnchor::<()>::majority_quorum(2), 2); + assert_eq!(AttestedTrustAnchor::<()>::majority_quorum(3), 2); + assert_eq!(AttestedTrustAnchor::<()>::majority_quorum(4), 3); + assert_eq!(AttestedTrustAnchor::<()>::majority_quorum(5), 3); + } + + #[test] + fn with_default_anchor_uses_the_compiled_in_default() { + let anchor = AttestedTrustAnchor::<()>::with_default_anchor( + Vec::new(), + chain::Id::try_from("nyx-testnet").unwrap(), + contract(), + ) + .unwrap(); + + assert_eq!(anchor.trusted_signers, default_trusted_signers()); + assert_eq!( + anchor.quorum, + AttestedTrustAnchor::<()>::majority_quorum(anchor.trusted_signers.len()) + ); + } + + #[test] + fn new_with_a_caller_supplied_set_is_unaffected_by_the_default() { + let custom = HashSet::from([*keypair(1).public_key()]); + let anchor = AttestedTrustAnchor::<()>::new( + Vec::new(), + custom.clone(), + 1, + chain::Id::try_from("nyx-testnet").unwrap(), + contract(), + ) + .unwrap(); + + assert_eq!(anchor.trusted_signers, custom); + assert_ne!(anchor.trusted_signers, default_trusted_signers()); + } + #[test] fn reach_quorum_accepts_k_distinct_agreeing_signers() { let a = keypair(1); diff --git a/openspec/changes/directory-attested-anchor/design.md b/openspec/changes/directory-attested-anchor/design.md index e95871940ee..19454d077ad 100644 --- a/openspec/changes/directory-attested-anchor/design.md +++ b/openspec/changes/directory-attested-anchor/design.md @@ -87,9 +87,19 @@ Unchanged: no expiry timestamp in the attestation; staleness is judged by the ca ### D8: A small, hardcoded, overridable default anchor -`AttestedTrustAnchor` ships a compiled-in default: a small set of Nym-SA-owned nym-api identity keys and endpoints, with a sensible default quorum threshold, exposed as a default-style constructor alongside the fully-configurable `new(sources, trusted_signers, quorum, chain_id, contract)`. This removes the "who do I trust" setup burden for the common case while leaving any operator free to override it entirely (e.g. one who does not trust Nym SA and wants to point at their own nym-api instance as the root of trust). The default is deliberately small and expected to be stable - rotating it requires a crate release, which is an accepted, rare cost (see Risks), not a live operational concern the way Tier-1 membership churn is. +Revised mid-implementation on where the constants live and how the quorum is derived (see below); the shape of the decision - a compiled-in default alongside the fully-configurable path - is unchanged. -The override half of this decision is only as usable as an operator's ability to learn their own nym-api's identity key in the first place, which today depends on incidental DKG dealer registration. This change does not solve that; see "External prerequisite" below. +`AttestedTrustAnchor::with_default_anchor(sources, chain_id, directory_contract)` builds an anchor from a compiled-in default trust root, alongside `new(sources, trusted_signers, quorum, chain_id, contract)` for callers who want to override. This removes the "who do I trust" setup burden for the common case while leaving any operator free to override it entirely (e.g. one who does not trust Nym SA and wants to point at their own nym-api instance as the root of trust). + +**Where the default lives.** The identity keys and endpoints are *not* defined in `nym-directory-client` itself - they live in `nym-network-defaults::mainnet` as `DIRECTORY_ATTESTATION_SOURCES: &[DirectoryAttestationSourceConst]` (a new small struct in `network.rs` pairing `api_url: &str` with `identity_ed25519_bs58: &str`), directly mirroring the existing `UPGRADE_MODE_ATTESTER_ED25519_BS58_PUBKEY` / `UPGRADE_MODE_ATTESTATION_URL` pattern already in that file. This was a mid-implementation correction: `nym-network-defaults` is deliberately dependency-starved (it is imported by the ecash contract, and its `Cargo.toml` explicitly warns against adding new dependencies), so the keys are stored as plain bs58 strings - not a typed `ed25519::PublicKey` - and parsed downstream in `nym-directory-client::default_trusted_signers()`, which already depends on `nym-crypto`. Only `mainnet.rs` hardcodes real values in that crate at all; every other network (sandbox, qa, ...) is populated from env vars via `NymNetworkDetails::new_from_env()`, so this default is mainnet-specific by construction - non-mainnet deployments are expected to call `new(...)` directly rather than get a compiled-in default. Mainnet is expected to run 3 nym-apis; other networks may run only 1, reinforcing that this is not a generalizable per-network constant. + +**Quorum is derived, not separately hardcoded.** Rather than shipping a `DIRECTORY_ATTESTATION_QUORUM` constant that must be hand-updated in lockstep with the signer list, `AttestedTrustAnchor::majority_quorum(signer_count) -> usize` (`signer_count / 2 + 1`, a public associated function) computes a simple majority from `DIRECTORY_ATTESTATION_SOURCES.len()` at construction time. This was a mid-implementation change from an earlier draft that hardcoded `K = 2` alongside `N = 2` identity keys: with a derived majority, adding mainnet's third nym-api's key to the list automatically moves the default from 2-of-2 to 2-of-3, with no code change to keep in sync. `new(...)` is unaffected - callers there still supply their own `quorum` explicitly (though they may also call `majority_quorum` themselves if they want the same policy). + +**Values are real, filled in by the user directly.** `DIRECTORY_ATTESTATION_SOURCES` holds the 2 currently-known mainnet nym-api identity keys and URLs (real values, not placeholders); a commented-out third entry documents the nym-api that cannot be added yet, pending the "External prerequisite" below. + +**Env-overridable, with automatic backfill for real deployments.** `nym_network_defaults::default_directory_attestation_sources()` (in `network.rs`, alongside `DirectoryAttestationSource` - an owned counterpart to the `&'static str`-based `DirectoryAttestationSourceConst`, needed since env-sourced values can't be `'static`) checks `env_configured()`; if set, it reads a `DIRECTORY_ATTESTATION_SOURCES` JSON env var (mirroring the existing `NYM_APIS`/`NYM_VPN_APIS` pattern), otherwise it falls back to the compiled `mainnet::DIRECTORY_ATTESTATION_SOURCES`. This generalizes D8 beyond mainnet: any deployment that sets this env var (directly, or via a future per-network `.env` file) gets its own default sources without needing a compiled Rust constant. Real mainnet binaries loading the static `envs/mainnet.env` file need no changes to that file to pick up the compiled default - `setup_env()` already ends with `mainnet::export_to_env_if_not_set()`, which backfills any var (including this new one) not already present from the compiled mainnet constants, exactly as it does today for `NYM_API`, `UPGRADE_MODE_ATTESTER_ED25519_BS58_PUBKEY`, etc. Verified end-to-end: loading `envs/mainnet.env` via `setup_env` correctly backfills and round-trips both real entries with no manual duplication of the JSON into that file. + +The override path is only as usable as an operator's ability to learn their own nym-api's identity key in the first place, which today depends on incidental DKG dealer registration (and, for 1 of mainnet's own 3 nym-apis, blocks even Nym SA from including it in the default above). This change does not solve that; see "External prerequisite" below. ### D9: Whole-directory verification decoupled from the client's own RPC connection @@ -99,7 +109,7 @@ The existing `recompute_accumulator` check in `verified_directory` is already so - [Quorum collusion] K colluding, trusted signers can attest a false snapshot. Mitigation: choose independently operated nym-apis and set K high relative to N; the whole-directory recompute still fails closed if the attested hashes do not match the served data. Residual: a fully colluding quorum could serve a self-consistent fake directory *and* fake identity bindings. This is the explicit trust assumption of attested mode - strictly weaker than the light client, strictly stronger than a single trusted RPC. - [Liveness at cadence boundaries] The seed-then-confirm `refresh()` flow (D6) already removes most of the risk of independent per-source "latest" queries splitting across a cadence boundary. Residual: if fewer than K sources currently hold the seeded height (some have not produced it yet, others have already aged it out), `refresh()` still fails. Mitigation: `QuorumNotReached { needed, agreed }` / `NoQuorumSnapshotForHeight` surface the shortfall clearly, and a caller can simply retry - a later attempt will likely seed a height more sources currently hold. -- [Default-anchor staleness] The shipped default is compiled in; if Nym SA ever needs to rotate those specific keys, every deployment relying on the default needs a new crate release to pick up the change. Mitigation: keep the default small (fewer keys to ever need rotating) and document the override path prominently so operators with stricter needs are not depending on it in the first place. This is the same staleness shape as the light-client checkpoint's trust period, just far rarer and smaller in blast radius. +- [Default-anchor staleness] The shipped default is compiled in; if Nym SA ever needs to rotate those specific keys, a deployment either needs a new crate release, or can pick up new values immediately via the `DIRECTORY_ATTESTATION_SOURCES` env var override (see D8) without waiting on one. Mitigation: keep the default small (fewer keys to ever need rotating) and document the override path prominently so operators with stricter needs are not depending on it in the first place. This is the same staleness shape as the light-client checkpoint's trust period, just far rarer and smaller in blast radius. The quorum threshold itself does not add to this - it is derived (`majority_quorum`) from however many keys are listed, so growing the list (e.g. mainnet's third nym-api) does not also require separately updating a threshold constant. - [Signer-set churn] Rotating a nym-api key (beyond the default) requires reconfiguring clients. Mitigation: the set is config-supplied, not hardcoded, for callers who override it; a future upgrade (see "Future direction") can derive the live set from chain state instead. - [Format churn when the producer lands] Mitigation: the canonical signing payload is defined now in the shared crate, and the producer follow-up reuses it verbatim. diff --git a/openspec/changes/directory-attested-anchor/tasks.md b/openspec/changes/directory-attested-anchor/tasks.md index 7a6191bcc54..a0d890d4427 100644 --- a/openspec/changes/directory-attested-anchor/tasks.md +++ b/openspec/changes/directory-attested-anchor/tasks.md @@ -43,9 +43,17 @@ just a discovery hint - so a lying seed only wastes a round-trip, never a false ## 4. Default anchor -- [ ] 4.1 Define compiled-in default anchor constants (Nym-SA-owned nym-api identity keys + a default quorum threshold; concrete key material and endpoints TBD at implementation time) -- [ ] 4.2 Implement a default-anchor constructor (e.g. `AttestedTrustAnchor::with_default_anchor(sources, chain_id, directory_contract)`) that builds the anchor with the default `trusted_signers`/quorum, alongside the fully-configurable `new(...)` for callers who want to override -- [ ] 4.3 Unit test: the default-anchor constructor produces an anchor whose `trusted_signers`/quorum match the compiled-in default; `new(...)` with a caller-supplied set is unaffected by the default +Placement revised mid-implementation (see `design.md` D8): the compiled-in constants do +not live in `nym-directory-client` itself, but in `nym-network-defaults::mainnet` - +that crate already hardcodes exactly this shape of thing +(`UPGRADE_MODE_ATTESTER_ED25519_BS58_PUBKEY` / `UPGRADE_MODE_ATTESTATION_URL`), and +`nym-directory-client` was already going to depend on it transitively via +`nym-validator-client`. Quorum is derived from the signer count (a majority) rather +than separately hardcoded, so the list and the threshold cannot drift out of sync. + +- [x] 4.1 Add `DirectoryAttestationSourceConst { api_url: &str, identity_ed25519_bs58: &str }` (compiled-in) and `DirectoryAttestationSource { api_url: String, identity_ed25519_bs58: String }` (owned, for env-sourced values) to `common/network-defaults/src/network.rs`, and a mainnet-only `DIRECTORY_ATTESTATION_SOURCES: &[DirectoryAttestationSourceConst]` (`#[cfg(feature = "network")]`) to `common/network-defaults/src/mainnet.rs`, holding the 2 currently-known real mainnet identity keys/URLs (a commented-out third entry documents the nym-api that cannot be added yet - see the "External prerequisite"). No separate quorum constant (see 4.2). Added `pub fn default_directory_attestation_sources() -> Vec`: reads a `DIRECTORY_ATTESTATION_SOURCES` JSON env var when `env_configured()` (mirroring `NYM_APIS`/`NYM_VPN_APIS`), else falls back to the compiled mainnet list - the `#[cfg(feature = "env")]` block guarding the env path had to be scoped *inside* the always-compiled function (not as an unconditional top-level `use crate::env_configured`/`use crate::var_names`), since `network.rs` compiles under `feature = "network"` alone and those items require `feature = "env"` too; also wired into `mainnet::export_to_env()`/`export_to_env_if_not_set()` so `setup_env()` backfills the var from the compiled default for any real binary that didn't have it in its static `.env` file (verified end-to-end against `envs/mainnet.env` - no manual duplication needed there). +- [x] 4.2 Add `nym-network-defaults` as a dependency of `nym-directory-client`; implement `AttestedTrustAnchor::majority_quorum(signer_count: usize) -> usize` (`signer_count / 2 + 1`, public) and a private `default_trusted_signers()` parsing `default_directory_attestation_sources()`'s bs58 keys into `HashSet` (`#[allow(clippy::expect_used)]` - the workspace denies `expect_used` by default); implement `AttestedTrustAnchor::with_default_anchor(sources, chain_id, directory_contract)` calling `new(sources, default_trusted_signers(), majority_quorum(...), chain_id, directory_contract)` +- [x] 4.3 Unit tests: `majority_quorum` matches simple-majority arithmetic for several signer counts; `with_default_anchor` produces an anchor whose `trusted_signers`/quorum match the compiled-in default; `new(...)` with a caller-supplied set is unaffected by the default ## 5. DirectoryTrustAnchor impl and re-exports From 1731d1edb08c78dfa47f2904a3504032960ec914 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C4=99drzej=20Stuczy=C5=84ski?= Date: Wed, 8 Jul 2026 15:13:49 +0100 Subject: [PATCH 5/6] expose snapshot related data within AttestedTrustAnchor --- .../src/anchor/attested.rs | 79 +++++++++++++++++++ common/nym-directory-client/src/anchor/mod.rs | 1 + .../directory-attested-anchor/tasks.md | 9 ++- 3 files changed, 85 insertions(+), 4 deletions(-) diff --git a/common/nym-directory-client/src/anchor/attested.rs b/common/nym-directory-client/src/anchor/attested.rs index e37daae847a..8ec71858bf6 100644 --- a/common/nym-directory-client/src/anchor/attested.rs +++ b/common/nym-directory-client/src/anchor/attested.rs @@ -1,6 +1,7 @@ // Copyright 2026 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 +use crate::anchor::{DirectoryTrustAnchor, TrustedDigest}; use crate::error::DirectoryClientError; use async_trait::async_trait; use cosmrs::AccountId; @@ -389,6 +390,35 @@ where .insert(height, trusted.clone()); Ok(trusted) } + + /// The trusted hash over the `NodeId -> ed25519 identity` mapping at `height` (see + /// [`crate::verify::node_identities_hash`]) - anchor-specific rather than part of + /// the shared [`DirectoryTrustAnchor`] trait, since `ProvenTrustAnchor` and + /// `LightClientAnchor` have no equivalent value to offer. + pub async fn trusted_node_identities_hash( + &self, + height: Height, + ) -> Result<[u8; 32], DirectoryClientError> { + Ok(self.snapshot_for(height).await?.node_identities_hash) + } +} + +#[async_trait] +impl DirectoryTrustAnchor for AttestedTrustAnchor +where + S: AttestationSource + Sync, +{ + async fn trusted_app_hash(&self, height: Height) -> Result { + Ok(self.snapshot_for(height).await?.app_hash) + } + + async fn trusted_digest(&self, height: Height) -> Result { + let snapshot = self.snapshot_for(height).await?; + Ok(TrustedDigest { + height, + accumulator: snapshot.accumulator, + }) + } } /// Append `bytes` prefixed with its u32 little-endian length, so adjacent @@ -1052,4 +1082,53 @@ mod tests { DirectoryClientError::NoQuorumSnapshotForHeight(999) )); } + + #[tokio::test] + async fn directory_trust_anchor_impl_returns_the_attested_values() { + let a = keypair(1); + let b = keypair(2); + let trusted = HashSet::from([*a.public_key(), *b.public_key()]); + let height = Height::from(100u32); + let hash = app_hash(7); + let mut acc = LtHash16::new(); + acc.add(b"leaf"); + let node_hash = [3u8; 32]; + + let sources = [&a, &b].map(|kp| { + let snapshot = signed_snapshot_with( + kp, + "nyx-testnet", + &contract(), + height, + hash.clone(), + acc.clone(), + node_hash, + ); + MockSource { + identity: *kp.public_key(), + latest: Some(snapshot.clone()), + by_height: HashMap::from([(height, snapshot)]), + } + }); + + let anchor = AttestedTrustAnchor::new( + sources.into(), + trusted, + 2, + chain::Id::try_from("nyx-testnet").unwrap(), + contract(), + ) + .unwrap(); + + assert_eq!(anchor.trusted_app_hash(height).await.unwrap(), hash); + + let digest = anchor.trusted_digest(height).await.unwrap(); + assert_eq!(digest.height, height); + assert_eq!(digest.accumulator, acc); + + assert_eq!( + anchor.trusted_node_identities_hash(height).await.unwrap(), + node_hash + ); + } } diff --git a/common/nym-directory-client/src/anchor/mod.rs b/common/nym-directory-client/src/anchor/mod.rs index 4888c68b1be..1d0db220d1a 100644 --- a/common/nym-directory-client/src/anchor/mod.rs +++ b/common/nym-directory-client/src/anchor/mod.rs @@ -17,6 +17,7 @@ mod helpers; pub mod light_client; pub mod proven; +pub use attested::{AttestationSource, AttestedTrustAnchor, DigestSnapshot, SignedDigestSnapshot}; #[cfg(feature = "light-client")] pub use light_client::{LightClientAnchor, nyx_default_options}; diff --git a/openspec/changes/directory-attested-anchor/tasks.md b/openspec/changes/directory-attested-anchor/tasks.md index a0d890d4427..baa3cc80152 100644 --- a/openspec/changes/directory-attested-anchor/tasks.md +++ b/openspec/changes/directory-attested-anchor/tasks.md @@ -57,10 +57,11 @@ than separately hardcoded, so the list and the threshold cannot drift out of syn ## 5. DirectoryTrustAnchor impl and re-exports -- [ ] 5.1 Implement `trusted_app_hash(H)`: `snapshot_for(H)` then return its `app_hash` -- [ ] 5.2 Implement `trusted_digest(H)`: `snapshot_for(H)` then return `TrustedDigest { height: H, accumulator }` (no ICS23 proof - the quorum attests the accumulator directly) -- [ ] 5.3 Expose the snapshot's `node_identities_hash` for a given height via an anchor-specific accessor (not part of the shared `DirectoryTrustAnchor` trait, so `ProvenTrustAnchor` / `LightClientAnchor` are untouched) -- [ ] 5.4 Re-export `AttestedTrustAnchor`, `AttestationSource`, `SignedDigestSnapshot`, `DigestSnapshot` from `src/anchor/mod.rs` +- [x] 5.1 Implement `trusted_app_hash(H)`: `snapshot_for(H)` then return its `app_hash` +- [x] 5.2 Implement `trusted_digest(H)`: `snapshot_for(H)` then return `TrustedDigest { height: H, accumulator }` (no ICS23 proof - the quorum attests the accumulator directly) +- [x] 5.3 Expose the snapshot's `node_identities_hash` for a given height via an anchor-specific accessor (`pub async fn trusted_node_identities_hash`, not part of the shared `DirectoryTrustAnchor` trait, so `ProvenTrustAnchor` / `LightClientAnchor` are untouched) +- [x] 5.4 Re-export `AttestedTrustAnchor`, `AttestationSource`, `SignedDigestSnapshot`, `DigestSnapshot` from `src/anchor/mod.rs` +- [x] 5.5 (added) Unit test `directory_trust_anchor_impl_returns_the_attested_values`: `trusted_app_hash`, `trusted_digest`, and `trusted_node_identities_hash` all return values matching a quorum-agreed mock snapshot at a given height ## 6. Data-source-agnostic whole-directory verification From f6e5293412eb0035d9ec9736a3be551ac7f9ddc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C4=99drzej=20Stuczy=C5=84ski?= Date: Wed, 8 Jul 2026 16:26:06 +0100 Subject: [PATCH 6/6] finalise the anchor and update specs --- .../src/anchor/attested.rs | 85 ++++- common/nym-directory-client/src/client.rs | 80 ++--- common/nym-directory-client/src/error.rs | 7 + common/nym-directory-client/src/verify.rs | 316 ++++++++++++++++-- .../.openspec.yaml | 0 .../design.md | 7 + .../proposal.md | 0 .../specs/directory-attested-anchor/spec.md | 0 .../specs/directory-retrieval-client/spec.md | 0 .../tasks.md | 238 +++++++++++++ .../directory-attested-anchor/tasks.md | 96 ------ .../specs/directory-attested-anchor/spec.md | 122 +++++++ .../specs/directory-retrieval-client/spec.md | 30 ++ 13 files changed, 785 insertions(+), 196 deletions(-) rename openspec/changes/{directory-attested-anchor => archive/2026-07-08-directory-attested-anchor}/.openspec.yaml (100%) rename openspec/changes/{directory-attested-anchor => archive/2026-07-08-directory-attested-anchor}/design.md (95%) rename openspec/changes/{directory-attested-anchor => archive/2026-07-08-directory-attested-anchor}/proposal.md (100%) rename openspec/changes/{directory-attested-anchor => archive/2026-07-08-directory-attested-anchor}/specs/directory-attested-anchor/spec.md (100%) rename openspec/changes/{directory-attested-anchor => archive/2026-07-08-directory-attested-anchor}/specs/directory-retrieval-client/spec.md (100%) create mode 100644 openspec/changes/archive/2026-07-08-directory-attested-anchor/tasks.md delete mode 100644 openspec/changes/directory-attested-anchor/tasks.md create mode 100644 openspec/specs/directory-attested-anchor/spec.md diff --git a/common/nym-directory-client/src/anchor/attested.rs b/common/nym-directory-client/src/anchor/attested.rs index 8ec71858bf6..15c3774313e 100644 --- a/common/nym-directory-client/src/anchor/attested.rs +++ b/common/nym-directory-client/src/anchor/attested.rs @@ -945,19 +945,47 @@ mod tests { )); } - struct MockSource { + /// Calls made to a [`MockAttestationSource`], in call order - mirrors + /// `nym_validator_client::rpc::mocks::MockRpcClient`'s `CallLog` pattern. + #[derive(Default)] + struct AttestationCallLog { + latest_snapshot: usize, + snapshot_at: Vec, + } + + /// In-memory [`AttestationSource`] serving pre-registered latest + per-height + /// signed snapshots, with a call log so tests can assert which sources were (or + /// were not) queried - mirrors `MockRpcClient`. `Clone` shares the same underlying + /// log (an `Arc>`), so a test can keep its own handle after moving a + /// clone into an anchor's `sources`. + #[derive(Clone)] + struct MockAttestationSource { identity: ed25519::PublicKey, latest: Option, by_height: HashMap, + call_log: std::sync::Arc>, + } + + impl MockAttestationSource { + /// Number of times [`AttestationSource::latest_snapshot`] was called. + fn latest_snapshot_calls(&self) -> usize { + self.call_log.lock().unwrap().latest_snapshot + } + + /// Heights passed to [`AttestationSource::snapshot_at`], in call order. + fn snapshot_at_calls(&self) -> Vec { + self.call_log.lock().unwrap().snapshot_at.clone() + } } #[async_trait] - impl AttestationSource for MockSource { + impl AttestationSource for MockAttestationSource { fn identity(&self) -> PublicKey { self.identity } async fn latest_snapshot(&self) -> Result { + self.call_log.lock().unwrap().latest_snapshot += 1; self.latest .clone() .ok_or(DirectoryClientError::NoQuorumSnapshotForHeight(0)) @@ -967,18 +995,20 @@ mod tests { &self, height: Height, ) -> Result { + self.call_log.lock().unwrap().snapshot_at.push(height); self.by_height.get(&height).cloned().ok_or( DirectoryClientError::NoQuorumSnapshotForHeight(height.value()), ) } } - fn mock_source(kp: &KeyPair, height: Height) -> MockSource { + fn mock_source(kp: &KeyPair, height: Height) -> MockAttestationSource { let snapshot = signed_snapshot(kp, "nyx-testnet", &contract(), height); - MockSource { + MockAttestationSource { identity: *kp.public_key(), latest: Some(snapshot.clone()), by_height: HashMap::from([(height, snapshot)]), + call_log: Default::default(), } } @@ -1005,6 +1035,50 @@ mod tests { assert_eq!(anchor.latest_snapshot_height().await.unwrap(), height); } + #[tokio::test] + async fn refresh_pins_the_height_and_a_cached_query_does_not_requery_sources() { + let a = keypair(1); + let b = keypair(2); + let trusted = HashSet::from([*a.public_key(), *b.public_key()]); + let source_a = mock_source(&a, Height::from(100u32)); + let source_b = mock_source(&b, Height::from(100u32)); + let sources = vec![source_a.clone(), source_b.clone()]; + + let anchor = AttestedTrustAnchor::new( + sources, + trusted, + 2, + chain::Id::try_from("nyx-testnet").unwrap(), + contract(), + ) + .unwrap(); + + let height = anchor.refresh().await.unwrap(); + assert_eq!(height, Height::from(100u32)); + + // exactly one source was asked for "latest" (the randomly-chosen seed); the + // seed itself is never re-queried for confirmation (excluded via `identity()`, + // see design.md D6), so exactly one `snapshot_at` call happened in total too + let latest_calls_after_refresh = + source_a.latest_snapshot_calls() + source_b.latest_snapshot_calls(); + let snapshot_at_calls_after_refresh = + source_a.snapshot_at_calls().len() + source_b.snapshot_at_calls().len(); + assert_eq!(latest_calls_after_refresh, 1); + assert_eq!(snapshot_at_calls_after_refresh, 1); + + // a later query for the now-cached height is served from cache - no source is + // queried again at all + assert!(anchor.trusted_app_hash(height).await.is_ok()); + assert_eq!( + source_a.latest_snapshot_calls() + source_b.latest_snapshot_calls(), + latest_calls_after_refresh + ); + assert_eq!( + source_a.snapshot_at_calls().len() + source_b.snapshot_at_calls().len(), + snapshot_at_calls_after_refresh + ); + } + #[tokio::test] async fn refresh_fails_when_sources_disagree() { let a = keypair(1); @@ -1104,10 +1178,11 @@ mod tests { acc.clone(), node_hash, ); - MockSource { + MockAttestationSource { identity: *kp.public_key(), latest: Some(snapshot.clone()), by_height: HashMap::from([(height, snapshot)]), + call_log: Default::default(), } }); diff --git a/common/nym-directory-client/src/client.rs b/common/nym-directory-client/src/client.rs index 72d1dc9daf6..0500b699e94 100644 --- a/common/nym-directory-client/src/client.rs +++ b/common/nym-directory-client/src/client.rs @@ -9,20 +9,18 @@ use crate::error::DirectoryClientError; use crate::key::{curated_entry_key, node_entry_key}; use crate::proof::{ProvenPresence, WASM_STORE_PATH, verify_wasm_store_presence}; use crate::verify::{ - DirectoryNode, DirectoryNodeEntry, ProvenNodeEntry, VerifiedDirectory, node_signature_verifies, - recompute_accumulator, + ProvenNodeEntry, VerifiedDirectory, node_signature_verifies, verify_directory, }; use nym_crypto::asymmetric::ed25519; use nym_directory_contract_common::{ - AllEntriesPagedResponse, CuratedEntry, DirectoryEntryRecord, EntryKey, KnownLabel, NodeEntry, + AllEntriesPagedResponse, CuratedEntry, DirectoryEntryRecord, EntryKey, NodeEntry, QueryMsg as DirectoryQueryMsg, }; use nym_mixnet_contract_common::nym_node::{NodeDetailsResponse, PagedNymNodeBondsResponse}; use nym_mixnet_contract_common::{NodeId, QueryMsg as MixnetQueryMsg}; use nym_validator_client::nyxd::contract_traits::NymContractsProvider; use nym_validator_client::nyxd::{CosmWasmClient, Height}; -use std::collections::{BTreeMap, HashMap}; -use std::str::FromStr; +use std::collections::HashMap; use tracing::error; /// A verifiable directory reader. Composes a trust anchor (which produces a digest to @@ -46,69 +44,33 @@ where /// /// The digest proof and every entry page are read at the SAME `height`, so a write /// committed mid-pagination cannot interleave into a set that matches no digest. - /// Fails closed: any anchor / query error, or a recomputed digest that does not - /// match the proven one, returns an error rather than unverified data. + /// A thin wrapper around [`verify_directory`] (a chain-connection-agnostic core, see + /// `verify.rs`): fetches records and node identities via `self.client` as before, + /// then delegates the recompute-and-attribute logic - existing behavior for every + /// anchor is unchanged. Fails closed: any anchor / query error, or a recomputed + /// digest that does not match the proven one, returns an error rather than + /// unverified data. pub async fn verified_directory( &self, height: Height, ) -> Result { - // 1. establish the digest we trust at this height (proof handled by the anchor) + // establish the digest we trust at this height (proof handled by the anchor) let trusted = self.anchor.trusted_digest(height).await?; - // 2. fetch the whole entry set at the SAME height + // fetch the whole entry set and node identities at the SAME height let records = self.all_entries_at(height).await?; - - // 3. recompute locally and compare (whole-set integrity in one shot) - if recompute_accumulator(&records) != trusted.accumulator { - return Err(DirectoryClientError::DigestMismatch); - } - - // 4. retrieve identity keys of all nym-nodes at this height let node_identities = self.all_node_identities_at(height).await?; - // 5. attribute each entry to its author (node signature vs admin authority) - let mut curated_entries = BTreeMap::new(); - let mut node_entries = BTreeMap::new(); - - for record in records { - match record { - DirectoryEntryRecord::Curated { key, entry } => { - curated_entries.insert(key, entry.data.into()); - } - DirectoryEntryRecord::Node { - node_id, - label, - entry: node_entry, - } => { - // verify the node signature on the submitted data - let verified = match node_identities.get(&node_id) { - Some(identity) => { - node_signature_verifies(node_id, &label, &node_entry, identity) - } - None => false, - }; - let entry = node_entries - .entry(node_id) - .or_insert(DirectoryNode::new(verified)); - entry.verified &= verified; - - let data = DirectoryNodeEntry::from(node_entry); - - if let Ok(known) = KnownLabel::from_str(&label) { - entry.known_labels.insert(known, data); - } else { - entry.unknown_labels.insert(label, data); - } - } - } - } - - Ok(VerifiedDirectory { - height: trusted.height, - accumulator: trusted.accumulator, - curated_entries, - node_entries, - }) + // no trusted node-identities hash here - this anchor-agnostic path + // authenticates node identities via the live (though unproven) query above, + // exactly as before this was extracted + verify_directory( + height, + records, + &node_identities, + &trusted.accumulator, + None, + ) } /// Retrieve and verify a single node entry `(node_id, label)` at `height` via its own diff --git a/common/nym-directory-client/src/error.rs b/common/nym-directory-client/src/error.rs index ee5a6312d13..a903953c2d5 100644 --- a/common/nym-directory-client/src/error.rs +++ b/common/nym-directory-client/src/error.rs @@ -97,4 +97,11 @@ pub enum DirectoryClientError { /// `AttestedTrustAnchor::new` was called with a degenerate quorum threshold. #[error("invalid quorum configuration: quorum {quorum} with {signers} trusted signers")] InvalidQuorumConfig { quorum: usize, signers: usize }, + + /// The data-source-agnostic whole-directory verification path + /// (`verify::verify_directory_offline`) was called without a trusted + /// node-identities hash to check against - today, only `AttestedTrustAnchor`'s + /// snapshot carries one. + #[error("no trusted node-identities hash is available to verify authorship against")] + NodeIdentitiesHashUnavailable, } diff --git a/common/nym-directory-client/src/verify.rs b/common/nym-directory-client/src/verify.rs index f5719075b05..cfbbcf932fd 100644 --- a/common/nym-directory-client/src/verify.rs +++ b/common/nym-directory-client/src/verify.rs @@ -5,6 +5,7 @@ //! locally from the retrieved entries and check it against a trusted digest, then //! attribute each node entry to its signing node. +use crate::error::DirectoryClientError; use nym_crypto::asymmetric::ed25519; use nym_directory_contract_common::{ DirectoryEntryRecord, KnownLabel, NodeEntry, node_signing_payload, @@ -12,7 +13,9 @@ use nym_directory_contract_common::{ use nym_lthash::LtHash16; use nym_mixnet_contract_common::NodeId; use nym_validator_client::nyxd::Height; -use std::collections::BTreeMap; +use std::borrow::Borrow; +use std::collections::{BTreeMap, HashMap}; +use std::str::FromStr; #[derive(Debug, Clone, PartialEq)] pub struct DirectoryNodeEntry { @@ -102,16 +105,14 @@ pub fn recompute_accumulator(records: &[DirectoryEntryRecord]) -> LtHash16 { /// length-prefixing is needed: every record is the same width, so the total buffer /// length alone fixes the record count, and a record's position alone fixes its field /// boundaries. -pub fn node_identities_hash<'a>( - identities: impl Iterator, -) -> [u8; 32] { - let mut pairs: Vec<_> = identities.collect(); +pub fn node_identities_hash(identities: &HashMap) -> [u8; 32] { + let mut pairs: Vec<_> = identities.iter().collect(); pairs.sort_unstable_by_key(|(node_id, _)| *node_id); let mut buf = Vec::new(); for (node_id, identity) in pairs { - buf.extend_from_slice(&node_id.to_be_bytes()); - buf.extend_from_slice(&identity.to_bytes()); + buf.extend_from_slice(&node_id.borrow().to_be_bytes()); + buf.extend_from_slice(&identity.borrow().to_bytes()); } blake3::hash(&buf).into() @@ -132,6 +133,102 @@ pub(crate) fn node_signature_verifies( identity.verify(payload, &signature).is_ok() } +/// Recompute the digest from pre-fetched `records` and check it against +/// `trusted_accumulator`, then attribute each node entry to its signing node - +/// needs no chain RPC connection at all, so `records`/`node_identities` can be sourced +/// from anywhere as long as `trusted_accumulator` (and, if checked, +/// `trusted_node_identities_hash`) came from a trust anchor. Fails closed +/// (`DigestMismatch`) on either recompute mismatching its trusted counterpart. +/// +/// `trusted_node_identities_hash: None` skips the node-identity check entirely - used +/// by `DirectoryClient::verified_directory`'s RPC-backed path, which authenticates +/// node identities via a live (though unproven) chain query instead, unchanged from +/// before this function existed. A caller with no chain connection at all should use +/// [`verify_directory_offline`], which requires the hash rather than silently skipping +/// the check. +pub fn verify_directory( + height: Height, + records: Vec, + node_identities: &HashMap, + trusted_accumulator: &LtHash16, + trusted_node_identities_hash: Option<[u8; 32]>, +) -> Result { + if recompute_accumulator(&records) != *trusted_accumulator { + return Err(DirectoryClientError::DigestMismatch); + } + + if let Some(trusted_hash) = trusted_node_identities_hash { + if node_identities_hash(node_identities) != trusted_hash { + return Err(DirectoryClientError::DigestMismatch); + } + } + + let mut curated_entries = BTreeMap::new(); + let mut node_entries = BTreeMap::new(); + + for record in records { + match record { + DirectoryEntryRecord::Curated { key, entry } => { + curated_entries.insert(key, entry.data.into()); + } + DirectoryEntryRecord::Node { + node_id, + label, + entry: node_entry, + } => { + // verify the node signature on the submitted data + let verified = match node_identities.get(&node_id) { + Some(identity) => { + node_signature_verifies(node_id, &label, &node_entry, identity) + } + None => false, + }; + let entry = node_entries + .entry(node_id) + .or_insert(DirectoryNode::new(verified)); + entry.verified &= verified; + + let data = DirectoryNodeEntry::from(node_entry); + + if let Ok(known) = KnownLabel::from_str(&label) { + entry.known_labels.insert(known, data); + } else { + entry.unknown_labels.insert(label, data); + } + } + } + } + + Ok(VerifiedDirectory { + height, + accumulator: trusted_accumulator.clone(), + curated_entries, + node_entries, + }) +} + +/// [`verify_directory`], but requiring a trusted node-identities hash rather than +/// silently skipping authorship verification when none is given - the entry point for +/// callers with no chain RPC connection at all, verifying against an anchor that can +/// supply one (today, only `AttestedTrustAnchor`, via its `trusted_node_identities_hash`). +pub fn verify_directory_offline( + height: Height, + records: Vec, + node_identities: &HashMap, + trusted_accumulator: &LtHash16, + trusted_node_identities_hash: Option<[u8; 32]>, +) -> Result { + let trusted_hash = + trusted_node_identities_hash.ok_or(DirectoryClientError::NodeIdentitiesHashUnavailable)?; + verify_directory( + height, + records, + node_identities, + trusted_accumulator, + Some(trusted_hash), + ) +} + #[cfg(test)] mod tests { use super::*; @@ -262,11 +359,8 @@ mod tests { fn node_identities_hash_is_deterministic() { let a = *keypair(1).public_key(); let b = *keypair(2).public_key(); - let pairs = [(1u32, a), (2, b)]; - assert_eq!( - node_identities_hash(pairs.iter()), - node_identities_hash(pairs.iter()) - ); + let pairs = [(1u32, a), (2, b)].into_iter().collect(); + assert_eq!(node_identities_hash(&pairs), node_identities_hash(&pairs)); } #[test] @@ -274,11 +368,11 @@ mod tests { let a = *keypair(1).public_key(); let b = *keypair(2).public_key(); let c = *keypair(3).public_key(); - let forward = [(1, a), (2, b), (3, c)]; - let shuffled = [(3, c), (1, a), (2, b)]; + let forward = [(1, a), (2, b), (3, c)].into_iter().collect(); + let shuffled = [(3, c), (1, a), (2, b)].into_iter().collect(); assert_eq!( - node_identities_hash(forward.iter()), - node_identities_hash(shuffled.iter()) + node_identities_hash(&forward), + node_identities_hash(&shuffled) ); } @@ -286,12 +380,9 @@ mod tests { fn node_identities_hash_is_sensitive_to_node_id_change() { let a = *keypair(1).public_key(); let b = *keypair(2).public_key(); - let base = [(1, a), (2, b)]; - let changed = [(1, a), (3, b)]; - assert_ne!( - node_identities_hash(base.iter()), - node_identities_hash(changed.iter()) - ); + let base = [(1, a), (2, b)].into_iter().collect(); + let changed = [(1, a), (3, b)].into_iter().collect(); + assert_ne!(node_identities_hash(&base), node_identities_hash(&changed)); } #[test] @@ -299,12 +390,9 @@ mod tests { let a = *keypair(1).public_key(); let b = *keypair(2).public_key(); let other = *keypair(3).public_key(); - let base = [(1, a), (2, b)]; - let changed = [(1, a), (2, other)]; - assert_ne!( - node_identities_hash(base.iter()), - node_identities_hash(changed.iter()) - ); + let base = [(1, a), (2, b)].into_iter().collect(); + let changed = [(1, a), (2, other)].into_iter().collect(); + assert_ne!(node_identities_hash(&base), node_identities_hash(&changed)); } #[test] @@ -312,19 +400,175 @@ mod tests { let a = *keypair(1).public_key(); let b = *keypair(2).public_key(); let c = *keypair(3).public_key(); - let base = [(1, a), (2, b)]; - let extra = [(1, a), (2, b), (3, c)]; - assert_ne!( - node_identities_hash(base.iter()), - node_identities_hash(extra.iter()) - ); + let base = [(1, a), (2, b)].into_iter().collect(); + let extra = [(1, a), (2, b), (3, c)].into_iter().collect(); + assert_ne!(node_identities_hash(&base), node_identities_hash(&extra)); } #[test] fn empty_node_identities_mapping_hashes_deterministically() { assert_eq!( - node_identities_hash(Vec::new().iter()), - node_identities_hash(Vec::new().iter()) + node_identities_hash(&HashMap::new()), + node_identities_hash(&HashMap::new()) + ); + } + + fn sample_directory() -> ( + Vec, + HashMap, + LtHash16, + [u8; 32], + ) { + let a = keypair(1); + let b = keypair(2); + let records = vec![ + signed_node_record(&a, 1, "sphinx_key", b"a"), + signed_node_record(&b, 2, "sphinx_key", b"b"), + curated_record("nym-api/1", b"c"), + ]; + let accumulator = recompute_accumulator(&records); + let node_identities = HashMap::from([(1, *a.public_key()), (2, *b.public_key())]); + let pairs: HashMap<_, _> = node_identities + .iter() + .map(|(id, key)| (*id, *key)) + .collect(); + let identities_hash = node_identities_hash(&pairs); + (records, node_identities, accumulator, identities_hash) + } + + #[test] + fn verify_directory_succeeds_and_attributes_authorship() { + let (records, node_identities, accumulator, identities_hash) = sample_directory(); + let height = Height::from(100u32); + + let verified = verify_directory( + height, + records, + &node_identities, + &accumulator, + Some(identities_hash), + ) + .unwrap(); + + assert_eq!(verified.height, height); + assert_eq!(verified.accumulator, accumulator); + assert_eq!(verified.curated_entries.len(), 1); + assert_eq!(verified.node_entries.len(), 2); + assert!(verified.node_entries.values().all(|n| n.verified)); + } + + #[test] + fn verify_directory_skips_the_node_identity_check_when_hash_is_none() { + let (records, node_identities, accumulator, _) = sample_directory(); + + // a wrong hash would be ignored entirely - `None` means "do not check" + assert!( + verify_directory( + Height::from(100u32), + records, + &node_identities, + &accumulator, + None, + ) + .is_ok() + ); + } + + #[test] + fn verify_directory_fails_closed_on_accumulator_mismatch() { + let (records, node_identities, _, identities_hash) = sample_directory(); + let wrong_accumulator = LtHash16::new(); + + let err = verify_directory( + Height::from(100u32), + records, + &node_identities, + &wrong_accumulator, + Some(identities_hash), + ) + .unwrap_err(); + assert!(matches!(err, DirectoryClientError::DigestMismatch)); + } + + #[test] + fn verify_directory_fails_closed_on_node_identities_hash_mismatch() { + let (records, node_identities, accumulator, _) = sample_directory(); + let wrong_hash = [0xffu8; 32]; + + let err = verify_directory( + Height::from(100u32), + records, + &node_identities, + &accumulator, + Some(wrong_hash), + ) + .unwrap_err(); + assert!(matches!(err, DirectoryClientError::DigestMismatch)); + } + + #[test] + fn verify_directory_offline_requires_a_node_identities_hash() { + let (records, node_identities, accumulator, _) = sample_directory(); + + let err = verify_directory_offline( + Height::from(100u32), + records, + &node_identities, + &accumulator, + None, + ) + .unwrap_err(); + assert!(matches!( + err, + DirectoryClientError::NodeIdentitiesHashUnavailable + )); + } + + #[test] + fn verify_directory_offline_succeeds_with_a_matching_hash() { + let (records, node_identities, accumulator, identities_hash) = sample_directory(); + + assert!( + verify_directory_offline( + Height::from(100u32), + records, + &node_identities, + &accumulator, + Some(identities_hash), + ) + .is_ok() ); } + + #[test] + fn verify_directory_offline_fails_closed_on_accumulator_mismatch() { + let (records, node_identities, _, identities_hash) = sample_directory(); + let wrong_accumulator = LtHash16::new(); + + let err = verify_directory_offline( + Height::from(100u32), + records, + &node_identities, + &wrong_accumulator, + Some(identities_hash), + ) + .unwrap_err(); + assert!(matches!(err, DirectoryClientError::DigestMismatch)); + } + + #[test] + fn verify_directory_offline_fails_closed_on_node_identities_hash_mismatch() { + let (records, node_identities, accumulator, _) = sample_directory(); + let wrong_hash = [0xffu8; 32]; + + let err = verify_directory_offline( + Height::from(100u32), + records, + &node_identities, + &accumulator, + Some(wrong_hash), + ) + .unwrap_err(); + assert!(matches!(err, DirectoryClientError::DigestMismatch)); + } } diff --git a/openspec/changes/directory-attested-anchor/.openspec.yaml b/openspec/changes/archive/2026-07-08-directory-attested-anchor/.openspec.yaml similarity index 100% rename from openspec/changes/directory-attested-anchor/.openspec.yaml rename to openspec/changes/archive/2026-07-08-directory-attested-anchor/.openspec.yaml diff --git a/openspec/changes/directory-attested-anchor/design.md b/openspec/changes/archive/2026-07-08-directory-attested-anchor/design.md similarity index 95% rename from openspec/changes/directory-attested-anchor/design.md rename to openspec/changes/archive/2026-07-08-directory-attested-anchor/design.md index 19454d077ad..e0f3b48f028 100644 --- a/openspec/changes/directory-attested-anchor/design.md +++ b/openspec/changes/archive/2026-07-08-directory-attested-anchor/design.md @@ -105,6 +105,13 @@ The override path is only as usable as an operator's ability to learn their own The existing `recompute_accumulator` check in `verified_directory` is already source-agnostic (it hashes whatever records it is handed); the only reason the *current* implementation needs an RPC connection is that `DirectoryClient::verified_directory` fetches those records (and node identities) via `self.client`, a bound `CosmWasmClient`. This change extracts the verification logic (digest recompute + node-identity-hash recompute + per-entry signature attribution) into a form that accepts pre-fetched data and needs only the anchor's trusted snapshot - no `CosmWasmClient` bound at all. `DirectoryClient::verified_directory` becomes a thin wrapper that fetches via `self.client` as before and calls into that shared logic, so existing RPC-backed callers (including `ProvenTrustAnchor` / `LightClientAnchor` users) see no behavior change. A caller using `AttestedTrustAnchor` who sourced entries and node identities from elsewhere (any nym-api, a mirror, a CDN) can call the decoupled path directly, with no `CosmWasmClient` in the picture. This path is only available when the anchor's trusted snapshot actually carries a `node_identities_hash` (today, only `AttestedTrustAnchor`); calling it against an anchor that does not returns a clear error rather than silently skipping authorship verification. +**Implementation shape (task 6).** Two pure, synchronous functions in `verify.rs`, neither taking an anchor or a `CosmWasmClient`: + +- `verify_directory(height, records, node_identities: &HashMap, trusted_accumulator: &LtHash16, trusted_node_identities_hash: Option<[u8; 32]>) -> Result` - the shared core (digest recompute, optional node-identity-hash recompute, per-entry attribution). `None` skips the node-identity check entirely; `DirectoryClient::verified_directory` calls this with `None`, reproducing its exact prior behavior for every anchor (it never had a trusted hash to check against, and still doesn't for `ProvenTrustAnchor` / `LightClientAnchor`). +- `verify_directory_offline(..., trusted_node_identities_hash: Option<[u8; 32]>)` - same signature, but treats `None` as `NodeIdentitiesHashUnavailable` rather than silently skipping. This is task 6.4's decoupled entry point. It does not take an anchor generically (that would need a new trait, since the accessor is deliberately not on `DirectoryTrustAnchor` - see D-for-task-5); the caller resolves the hash themselves from whichever concrete anchor they hold (today, only `AttestedTrustAnchor::trusted_node_identities_hash`) and passes the `Option` in. + +Both recompute mismatches (accumulator, node-identities hash) fail closed with the SAME `DigestMismatch` variant - task 7 deliberately does not add a second "hash mismatch" variant, treating both as the same class of integrity failure. + ## Risks / Trade-offs - [Quorum collusion] K colluding, trusted signers can attest a false snapshot. Mitigation: choose independently operated nym-apis and set K high relative to N; the whole-directory recompute still fails closed if the attested hashes do not match the served data. Residual: a fully colluding quorum could serve a self-consistent fake directory *and* fake identity bindings. This is the explicit trust assumption of attested mode - strictly weaker than the light client, strictly stronger than a single trusted RPC. diff --git a/openspec/changes/directory-attested-anchor/proposal.md b/openspec/changes/archive/2026-07-08-directory-attested-anchor/proposal.md similarity index 100% rename from openspec/changes/directory-attested-anchor/proposal.md rename to openspec/changes/archive/2026-07-08-directory-attested-anchor/proposal.md diff --git a/openspec/changes/directory-attested-anchor/specs/directory-attested-anchor/spec.md b/openspec/changes/archive/2026-07-08-directory-attested-anchor/specs/directory-attested-anchor/spec.md similarity index 100% rename from openspec/changes/directory-attested-anchor/specs/directory-attested-anchor/spec.md rename to openspec/changes/archive/2026-07-08-directory-attested-anchor/specs/directory-attested-anchor/spec.md diff --git a/openspec/changes/directory-attested-anchor/specs/directory-retrieval-client/spec.md b/openspec/changes/archive/2026-07-08-directory-attested-anchor/specs/directory-retrieval-client/spec.md similarity index 100% rename from openspec/changes/directory-attested-anchor/specs/directory-retrieval-client/spec.md rename to openspec/changes/archive/2026-07-08-directory-attested-anchor/specs/directory-retrieval-client/spec.md diff --git a/openspec/changes/archive/2026-07-08-directory-attested-anchor/tasks.md b/openspec/changes/archive/2026-07-08-directory-attested-anchor/tasks.md new file mode 100644 index 00000000000..379e8aea2f5 --- /dev/null +++ b/openspec/changes/archive/2026-07-08-directory-attested-anchor/tasks.md @@ -0,0 +1,238 @@ +## 1. Canonical attestation encoding + +Lives in `nym-directory-client` itself, not a contract-common crate: neither function is +ever consumed by a contract, only by this crate (verifying) and the not-yet-built nym-api +producer (signing) - the same two-off-chain-peers pairing `recompute_accumulator` already +serves from `verify.rs`. Placement revisited mid-implementation (see `design.md` D3); the +producer, when it lands, can decide then whether to depend on this crate or extract a +shared piece, once its real constraints are known. + +- [x] 1.1 Add + `digest_snapshot_signing_payload(chain_id: &str, contract: &AccountId, height: Height, app_hash: &AppHash, accumulator: &LtHash16, node_identities_hash: &[u8; 32]) -> Vec` + to `common/nym-directory-client/src/anchor/attested.rs`, with its own local length-prefixing helper and a + domain-separation tag (so a snapshot signature can never collide with a `node_signing_payload` signature) +- [x] 1.2 N/A - not a shared crate, no re-export surface; `pub(crate)` within `nym-directory-client`, consumed by + `attested.rs` itself (task 2/3) +- [x] 1.3 Unit tests: payload is deterministic and field-sensitive (differs on any of chain-id / contract / height / + app_hash / accumulator / node_identities_hash); length-prefix framing disambiguates adjacent variable-length fields; + domain tag differs from a representative `node_signing_payload` output +- [x] 1.4 Add `node_identities_hash` (sorted, fixed-width-per-record, plain `blake3` hash - not an LtHash accumulator, + since it is recomputed fresh each time rather than incrementally updated) to + `common/nym-directory-client/src/verify.rs`, next to `recompute_accumulator` +- [x] 1.5 Unit tests for `node_identities_hash`: deterministic, sensitive to any `(node_id, identity)` or membership + change, order-independent (sorts internally so caller iteration order does not matter) + +## 2. Attestation types and transport trait (client crate) + +`trusted_signers`/`trusted` use `HashSet`, not the originally-sketched +`BTreeSet`: `ed25519::PublicKey` derives `Hash` but not `Ord` (confirmed in +`common/crypto/src/asymmetric/ed25519/mod.rs`), and a signer allowlist has no ordering +requirement to justify adding one - membership testing is all quorum counting needs. + +- [x] 2.1 Define + `DigestSnapshot { chain_id: chain::Id, directory_contract: AccountId, height: Height, app_hash: AppHash, accumulator: LtHash16, node_identities_hash: [u8; 32] }` ( + richer types than the original sketch - all confirmed to have serde support: `chain::Id`/`AccountId`/`Height` via + hand-written impls, `AppHash` via `cosmrs::tendermint::serializers::apphash`, `LtHash16` via a new `serde` feature on + `nym-lthash`, see task 1's follow-up) and + `SignedDigestSnapshot { snapshot: DigestSnapshot, signer: ed25519::PublicKey, signature: Vec }` in + `src/anchor/attested.rs` (both `Serialize`/`Deserialize`; signature kept as bytes so malformed data is a verification + failure, not a decode panic - mirrors `DirectoryNodeEntry`) +- [x] 2.2 Implement + `SignedDigestSnapshot::verify(&self, trusted: &HashSet, chain_id: &str, contract: &AccountId) -> bool`: + signer in trusted set AND chain-id + contract match AND ed25519 signature verifies over + `digest_snapshot_signing_payload(..)` (mirror `node_signature_verifies`); unit tests: valid attestation accepted, + untrusted signer / mismatched chain-id / mismatched contract / forged or malformed signature all rejected +- [x] 2.3 Define + `#[async_trait] pub trait AttestationSource { fn identity(&self) -> ed25519::PublicKey; async fn latest_snapshot(&self) -> Result; async fn snapshot_at(&self, height: Height) -> Result; }` ( + `identity()` added mid-implementation, ahead of the original sketch - a sync, no-network way for the anchor to + recognize which source produced a given attestation, used by `refresh()`, task 3.5, to avoid re-querying the seed's + own source) + +## 3. AttestedTrustAnchor core + +`refresh()`'s flow was revised mid-implementation (see `design.md` D6): rather than +comparing every source's independently-reported "latest" (which can split across a +cadence boundary if sources are not perfectly in lockstep), it seeds a height from the +first successful `latest_snapshot()` response, then asks every source's `snapshot_at` +that same height and reaches quorum over all of it. The seed is untrusted at that point - +just a discovery hint - so a lying seed only wastes a round-trip, never a false accept. + +- [x] 3.1 Define `TrustedSnapshot { app_hash: AppHash, accumulator: LtHash16, node_identities_hash: [u8; 32] }` and + private `AttestedTrustAnchorState { snapshots: BTreeMap, latest: Option }` +- [x] 3.2 Define + `AttestedTrustAnchor { sources: Vec, trusted_signers: HashSet, quorum: usize, chain_id: chain::Id, directory_contract: AccountId, state: Mutex }` ( + `chain_id` typed as `chain::Id`, matching `DigestSnapshot` and `verify`, not the originally-sketched `String`) +- [x] 3.3 Implement `new(sources, trusted_signers, quorum, chain_id, directory_contract)` validating + `1 <= quorum <= trusted_signers.len()` (error otherwise) +- [x] 3.4 Implement a private + `reach_quorum(&self, candidates: Vec) -> Result<(Height, TrustedSnapshot), DirectoryClientError>`: + filter to valid attestations (via `verify`), group by `(height, app_hash, accumulator, node_identities_hash)` via a + `HashMap>` keyed on a manual `Hash` impl added to `DigestSnapshot` (not + the linear scan originally planned, once `LtHash16` gained a `Hash` impl to build it from - see design.md D3/D6), + count DISTINCT signer keys per group, accept the *first* group (in candidate-arrival order, checked as each candidate + is folded in, so the result is deterministic regardless of `HashMap` iteration order) reaching `quorum`, else + `QuorumNotReached { needed, agreed }` (`agreed` = the largest distinct-signer count seen across any single group, via + an order-independent `max()`) +- [x] 3.5 Implement `refresh(&self) -> Result`: try sources' `latest_snapshot()` in + shuffled order, one at a time, taking the first successful response as a height seed `H` (see design.md D6 for an open + latency concern with this vs. querying concurrently); query every *other* source's (via `identity()`, task 2.3) + `snapshot_at(H)` concurrently; `reach_quorum` over the seed plus all of those responses; insert into `snapshots`, set + `latest`, return the agreed height +- [x] 3.6 Implement `latest_snapshot_height(&self) -> Result`: return cached `latest` or + call `refresh()` +- [x] 3.7 Implement a private `snapshot_for(&self, height) -> Result`: cache hit + on `height` returns immediately; on miss query all sources' `snapshot_at(height)`, `reach_quorum` (verifying the + returned height equals the requested one, else `NoQuorumSnapshotForHeight(height)` even if some other height reached + quorum), cache, return; a height the quorum cannot attest at all returns `NoQuorumSnapshotForHeight(height)` + +## 4. Default anchor + +Placement revised mid-implementation (see `design.md` D8): the compiled-in constants do +not live in `nym-directory-client` itself, but in `nym-network-defaults::mainnet` - +that crate already hardcodes exactly this shape of thing +(`UPGRADE_MODE_ATTESTER_ED25519_BS58_PUBKEY` / `UPGRADE_MODE_ATTESTATION_URL`), and +`nym-directory-client` was already going to depend on it transitively via +`nym-validator-client`. Quorum is derived from the signer count (a majority) rather +than separately hardcoded, so the list and the threshold cannot drift out of sync. + +- [x] 4.1 Add `DirectoryAttestationSourceConst { api_url: &str, identity_ed25519_bs58: &str }` (compiled-in) and + `DirectoryAttestationSource { api_url: String, identity_ed25519_bs58: String }` (owned, for env-sourced values) to + `common/network-defaults/src/network.rs`, and a mainnet-only + `DIRECTORY_ATTESTATION_SOURCES: &[DirectoryAttestationSourceConst]` (`#[cfg(feature = "network")]`) to + `common/network-defaults/src/mainnet.rs`, holding the 2 currently-known real mainnet identity keys/URLs (a + commented-out third entry documents the nym-api that cannot be added yet - see the "External prerequisite"). No + separate quorum constant (see 4.2). Added + `pub fn default_directory_attestation_sources() -> Vec`: reads a + `DIRECTORY_ATTESTATION_SOURCES` JSON env var when `env_configured()` (mirroring `NYM_APIS`/`NYM_VPN_APIS`), else falls + back to the compiled mainnet list - the `#[cfg(feature = "env")]` block guarding the env path had to be scoped + *inside* the always-compiled function (not as an unconditional top-level `use crate::env_configured`/ + `use crate::var_names`), since `network.rs` compiles under `feature = "network"` alone and those items require + `feature = "env"` too; also wired into `mainnet::export_to_env()`/`export_to_env_if_not_set()` so `setup_env()` + backfills the var from the compiled default for any real binary that didn't have it in its static `.env` file ( + verified end-to-end against `envs/mainnet.env` - no manual duplication needed there). +- [x] 4.2 Add `nym-network-defaults` as a dependency of `nym-directory-client`; implement + `AttestedTrustAnchor::majority_quorum(signer_count: usize) -> usize` (`signer_count / 2 + 1`, public) and a private + `default_trusted_signers()` parsing `default_directory_attestation_sources()`'s bs58 keys into + `HashSet` (`#[allow(clippy::expect_used)]` - the workspace denies `expect_used` by default); + implement `AttestedTrustAnchor::with_default_anchor(sources, chain_id, directory_contract)` calling + `new(sources, default_trusted_signers(), majority_quorum(...), chain_id, directory_contract)` +- [x] 4.3 Unit tests: `majority_quorum` matches simple-majority arithmetic for several signer counts; + `with_default_anchor` produces an anchor whose `trusted_signers`/quorum match the compiled-in default; `new(...)` with + a caller-supplied set is unaffected by the default + +## 5. DirectoryTrustAnchor impl and re-exports + +- [x] 5.1 Implement `trusted_app_hash(H)`: `snapshot_for(H)` then return its `app_hash` +- [x] 5.2 Implement `trusted_digest(H)`: `snapshot_for(H)` then return `TrustedDigest { height: H, accumulator }` (no + ICS23 proof - the quorum attests the accumulator directly) +- [x] 5.3 Expose the snapshot's `node_identities_hash` for a given height via an anchor-specific accessor ( + `pub async fn trusted_node_identities_hash`, not part of the shared `DirectoryTrustAnchor` trait, so + `ProvenTrustAnchor` / `LightClientAnchor` are untouched) +- [x] 5.4 Re-export `AttestedTrustAnchor`, `AttestationSource`, `SignedDigestSnapshot`, `DigestSnapshot` from + `src/anchor/mod.rs` +- [x] 5.5 (added) Unit test `directory_trust_anchor_impl_returns_the_attested_values`: `trusted_app_hash`, + `trusted_digest`, and `trusted_node_identities_hash` all return values matching a quorum-agreed mock snapshot at a + given height + +## 6. Data-source-agnostic whole-directory verification + +The `node_identities_hash` check needed a policy decision not fully spelled out by the +original task text: it had to be OPTIONAL on the shared core, because the existing +RPC-backed path (6.3) has no trusted hash to offer at all (it always authenticates node +identities via a live, unproven chain query, unchanged) and must keep behaving exactly +as before. The new decoupled path (6.4) needs the opposite policy - no hash is a hard +error, not a silent skip (per `design.md` D9). Resolved with one shared core taking +`Option<[u8; 32]>` (`Some` checks it, `None` skips it - serving 6.3 unchanged) plus a +second function layering "must be `Some`" on top for 6.4. Both live in `verify.rs` +(pure, synchronous functions over already-fetched data - no anchor, no async, no +`CosmWasmClient` anywhere in the signature). + +- [x] 6.1 Extract the body of `DirectoryClient::verified_directory` (digest recompute, per-entry authorship attribution) + into + `verify::verify_directory(height, records, node_identities: &HashMap<..>, trusted_accumulator, trusted_node_identities_hash: Option<[u8; 32]>)`, + needing no `CosmWasmClient` at all +- [x] 6.2 Added the node-identity hash recompute check (using `node_identities_hash` from 1.4) inside + `verify_directory`, gated on `trusted_node_identities_hash` being `Some`; failing closed with the SAME + `DigestMismatch` variant used for the accumulator check (task 7 adds no separate "hash mismatch" variant - both are + the same class of integrity failure) +- [x] 6.3 `DirectoryClient::verified_directory` is now a thin wrapper: fetch records + node identities via `self.client` + as before, then `verify_directory(.., None)` - the RPC-backed path never had a trusted node-identities hash to check, + so `None` reproduces its exact prior behavior for every anchor +- [x] 6.4 Added + `verify::verify_directory_offline(height, records, node_identities, trusted_accumulator, trusted_node_identities_hash: Option<[u8; 32]>)`: + a free function (not a method, not bound to any anchor type or `DirectoryClient`) layering "the hash must be `Some`" + over `verify_directory`, returning `NodeIdentitiesHashUnavailable` on `None` - the caller resolves the hash themselves + from whichever anchor they hold (today, only `AttestedTrustAnchor::trusted_node_identities_hash`) and passes it in, + rather than this function being generic over anchor types +- [x] 6.5 (added) Unit tests in `verify.rs`: success + authorship attribution; `None` skips the identity check (locks in + 6.3's contract); accumulator mismatch and node-identities-hash mismatch both fail closed with `DigestMismatch`; + `verify_directory_offline` requires `Some` and returns `NodeIdentitiesHashUnavailable` otherwise, succeeds with a + matching hash + +## 7. Error handling + +- [x] 7.1 Add to `DirectoryClientError`: ~~`QuorumNotReached { needed: usize, agreed: usize }`~~, + ~~`NoQuorumSnapshotForHeight(u64)`~~, ~~`InvalidQuorumConfig { quorum: usize, signers: usize }`~~ (all three added + early, needed by task 3), `NodeIdentitiesHashUnavailable` (added, used by 6.4). The attestation-transport / decode + variant is deliberately NOT added: nothing in this change constructs one - the concrete HTTP `AttestationSource` is a + Non-Goal deferred to the nym-api producer follow-up (see `design.md`), and its real transport/decode failure shape ( + HTTP client error type, wire encoding) is unknown until that change exists. Adding a variant now would be a guess with + no call site to validate it against; that follow-up should add whatever fits its actual error surface. + +## 8. Tests (mock transport) + +Most of 8.2-8.11 turned out to already be satisfied by tests written eagerly alongside +tasks 3/5/6, rather than needing new ones written now - cross-referenced below by name +instead of duplicated. 8.1 and 8.7 were genuine gaps (the old `MockSource` had no call +log) and are newly done. 8.12 is flagged, not done - see its note. + +- [x] 8.1 Promoted `MockSource` (task 3's own test module) into `MockAttestationSource`: added an `AttestationCallLog` ( + `latest_snapshot: usize`, `snapshot_at: Vec`) behind `Arc>`, `#[derive(Clone)]` on the source + itself (clones share the same log, mirroring `MockRpcClient`'s pattern) so a test can keep a handle after moving a + clone into an anchor's `sources`, plus `latest_snapshot_calls()`/`snapshot_at_calls()` accessors. The seeded-`KeyPair` + signed-snapshot builder already existed (`mock_source`, `signed_snapshot`/`signed_snapshot_with`); reused, not rebuilt +- [x] 8.2 Covered by `reach_quorum_accepts_k_distinct_agreeing_signers` (quorum reached) + + `directory_trust_anchor_impl_returns_the_attested_values` (task 5.5 - `trusted_app_hash`/`trusted_digest`/ + `trusted_node_identities_hash` all match a quorum-agreed mock snapshot) +- [x] 8.3 Covered by `reach_quorum_fails_with_fewer_than_k_agreeing_signers` +- [x] 8.4 Covered by `reach_quorum_counts_a_duplicated_signer_once` +- [x] 8.5 Covered by `reach_quorum_ignores_untrusted_or_invalid_attestations` (untrusted signer, forged signature) plus + `verify_rejects_an_untrusted_signer` / `verify_rejects_a_mismatched_chain_id_or_contract` / + `verify_rejects_a_forged_or_malformed_signature` (task 2.2, exercising the same `verify()` gate `reach_quorum` calls + internally) +- [x] 8.6 Covered by `reach_quorum_rejects_disagreeing_signers` +- [x] 8.7 New: `refresh_pins_the_height_and_a_cached_query_does_not_requery_sources` - after `refresh()`, exactly one + source was asked for `latest_snapshot` (the seed) and exactly one `snapshot_at` call happened in total (the confirm + round, excluding the seed itself); a subsequent `trusted_app_hash(H)` for that now-cached height leaves both + call-count totals unchanged. Summed across both mock sources rather than asserting on a specific one, since which + source is chosen as seed is randomized (verified stable across repeated runs) +- [x] 8.8 Covered by `snapshot_for_returns_the_cached_value_on_a_second_call` (first call is a genuine `snapshot_at` + -backed fetch, not yet cached) + `snapshot_for_rejects_a_height_no_quorum_can_attest` (`NoQuorumSnapshotForHeight`) +- [x] 8.9 Covered by `new_rejects_zero_quorum` + `new_rejects_quorum_exceeding_signer_count` +- [x] 8.10 Covered by `verify_directory_offline_succeeds_with_a_matching_hash` (new) + + `verify_directory_offline_fails_closed_on_accumulator_mismatch` (new) + + `verify_directory_offline_fails_closed_on_node_identities_hash_mismatch` (new) - added directly against + `verify_directory_offline` itself for precise coverage, rather than relying on `verify_directory`'s equivalent tests ( + task 6.5) transitively covering its thin wrapper +- [x] 8.11 Covered by `verify_directory_offline_requires_a_node_identities_hash` (task 6.5) - since 6.4 does not take an + anchor generically (see `design.md`'s task-6 addendum), "an anchor without one" is exercised as the caller passing + `None`, which is the only way this ever happens regardless of anchor type +- [ ] 8.12 NOT done - flagged rather than built. `DirectoryClient::verified_directory`'s RPC-backed path needs a mock + `CosmWasmClient` (for `query_contract_smart_at_height`, serving `AllEntries` / `GetNymNodeBondsPaged` responses) plus + a mock RPC header lookup for `ProvenTrustAnchor::trusted_app_hash`. `nym-validator-client`'s existing + `MockRpcClient` (already a dev-dependency here, used by `light_client.rs`'s tests) only implements `commit`/ + `validators` - `perform` (which `query_contract_smart_at_height` needs) is `unimplemented!()`, so it cannot serve this + test as-is. Building this needs either extending `MockRpcClient` in `nym-validator-client` (a different crate, + benefits future work there too) or hand-rolling a local mock implementing the `CosmWasmClient` + + `NymContractsProvider` trait surface (a nontrivial amount of boilerplate for traits with many required methods). Given + there is also no pre-existing test to regress against (this is a new test, not a check against prior behavior), and + the refactor itself (6.3) is a small, directly-reviewed 1:1 extraction, this was left for a follow-up decision rather + than building either option unprompted + +## 9. Verification + +- [x] 9.1 `cargo test -p nym-directory-contract-common --lib` passes (new payload + node-identity-hash tests) +- [x] 9.2 `cargo test -p nym-directory-client --lib` passes (attested anchor tests + decoupled verification tests + + existing tests) +- [x] 9.3 `cargo build -p nym-directory-client` and `cargo build -p nym-directory-client --features light-client` both + succeed (attested anchor is not feature-gated and must build in both) diff --git a/openspec/changes/directory-attested-anchor/tasks.md b/openspec/changes/directory-attested-anchor/tasks.md deleted file mode 100644 index baa3cc80152..00000000000 --- a/openspec/changes/directory-attested-anchor/tasks.md +++ /dev/null @@ -1,96 +0,0 @@ -## 1. Canonical attestation encoding - -Lives in `nym-directory-client` itself, not a contract-common crate: neither function is -ever consumed by a contract, only by this crate (verifying) and the not-yet-built nym-api -producer (signing) - the same two-off-chain-peers pairing `recompute_accumulator` already -serves from `verify.rs`. Placement revisited mid-implementation (see `design.md` D3); the -producer, when it lands, can decide then whether to depend on this crate or extract a -shared piece, once its real constraints are known. - -- [x] 1.1 Add `digest_snapshot_signing_payload(chain_id: &str, contract: &AccountId, height: Height, app_hash: &AppHash, accumulator: &LtHash16, node_identities_hash: &[u8; 32]) -> Vec` to `common/nym-directory-client/src/anchor/attested.rs`, with its own local length-prefixing helper and a domain-separation tag (so a snapshot signature can never collide with a `node_signing_payload` signature) -- [x] 1.2 N/A - not a shared crate, no re-export surface; `pub(crate)` within `nym-directory-client`, consumed by `attested.rs` itself (task 2/3) -- [x] 1.3 Unit tests: payload is deterministic and field-sensitive (differs on any of chain-id / contract / height / app_hash / accumulator / node_identities_hash); length-prefix framing disambiguates adjacent variable-length fields; domain tag differs from a representative `node_signing_payload` output -- [x] 1.4 Add `node_identities_hash` (sorted, fixed-width-per-record, plain `blake3` hash - not an LtHash accumulator, since it is recomputed fresh each time rather than incrementally updated) to `common/nym-directory-client/src/verify.rs`, next to `recompute_accumulator` -- [x] 1.5 Unit tests for `node_identities_hash`: deterministic, sensitive to any `(node_id, identity)` or membership change, order-independent (sorts internally so caller iteration order does not matter) - -## 2. Attestation types and transport trait (client crate) - -`trusted_signers`/`trusted` use `HashSet`, not the originally-sketched -`BTreeSet`: `ed25519::PublicKey` derives `Hash` but not `Ord` (confirmed in -`common/crypto/src/asymmetric/ed25519/mod.rs`), and a signer allowlist has no ordering -requirement to justify adding one - membership testing is all quorum counting needs. - -- [x] 2.1 Define `DigestSnapshot { chain_id: chain::Id, directory_contract: AccountId, height: Height, app_hash: AppHash, accumulator: LtHash16, node_identities_hash: [u8; 32] }` (richer types than the original sketch - all confirmed to have serde support: `chain::Id`/`AccountId`/`Height` via hand-written impls, `AppHash` via `cosmrs::tendermint::serializers::apphash`, `LtHash16` via a new `serde` feature on `nym-lthash`, see task 1's follow-up) and `SignedDigestSnapshot { snapshot: DigestSnapshot, signer: ed25519::PublicKey, signature: Vec }` in `src/anchor/attested.rs` (both `Serialize`/`Deserialize`; signature kept as bytes so malformed data is a verification failure, not a decode panic - mirrors `DirectoryNodeEntry`) -- [x] 2.2 Implement `SignedDigestSnapshot::verify(&self, trusted: &HashSet, chain_id: &str, contract: &AccountId) -> bool`: signer in trusted set AND chain-id + contract match AND ed25519 signature verifies over `digest_snapshot_signing_payload(..)` (mirror `node_signature_verifies`); unit tests: valid attestation accepted, untrusted signer / mismatched chain-id / mismatched contract / forged or malformed signature all rejected -- [x] 2.3 Define `#[async_trait] pub trait AttestationSource { fn identity(&self) -> ed25519::PublicKey; async fn latest_snapshot(&self) -> Result; async fn snapshot_at(&self, height: Height) -> Result; }` (`identity()` added mid-implementation, ahead of the original sketch - a sync, no-network way for the anchor to recognize which source produced a given attestation, used by `refresh()`, task 3.5, to avoid re-querying the seed's own source) - -## 3. AttestedTrustAnchor core - -`refresh()`'s flow was revised mid-implementation (see `design.md` D6): rather than -comparing every source's independently-reported "latest" (which can split across a -cadence boundary if sources are not perfectly in lockstep), it seeds a height from the -first successful `latest_snapshot()` response, then asks every source's `snapshot_at` -that same height and reaches quorum over all of it. The seed is untrusted at that point - -just a discovery hint - so a lying seed only wastes a round-trip, never a false accept. - -- [x] 3.1 Define `TrustedSnapshot { app_hash: AppHash, accumulator: LtHash16, node_identities_hash: [u8; 32] }` and private `AttestedTrustAnchorState { snapshots: BTreeMap, latest: Option }` -- [x] 3.2 Define `AttestedTrustAnchor { sources: Vec, trusted_signers: HashSet, quorum: usize, chain_id: chain::Id, directory_contract: AccountId, state: Mutex }` (`chain_id` typed as `chain::Id`, matching `DigestSnapshot` and `verify`, not the originally-sketched `String`) -- [x] 3.3 Implement `new(sources, trusted_signers, quorum, chain_id, directory_contract)` validating `1 <= quorum <= trusted_signers.len()` (error otherwise) -- [x] 3.4 Implement a private `reach_quorum(&self, candidates: Vec) -> Result<(Height, TrustedSnapshot), DirectoryClientError>`: filter to valid attestations (via `verify`), group by `(height, app_hash, accumulator, node_identities_hash)` via a `HashMap>` keyed on a manual `Hash` impl added to `DigestSnapshot` (not the linear scan originally planned, once `LtHash16` gained a `Hash` impl to build it from - see design.md D3/D6), count DISTINCT signer keys per group, accept the *first* group (in candidate-arrival order, checked as each candidate is folded in, so the result is deterministic regardless of `HashMap` iteration order) reaching `quorum`, else `QuorumNotReached { needed, agreed }` (`agreed` = the largest distinct-signer count seen across any single group, via an order-independent `max()`) -- [x] 3.5 Implement `refresh(&self) -> Result`: try sources' `latest_snapshot()` in shuffled order, one at a time, taking the first successful response as a height seed `H` (see design.md D6 for an open latency concern with this vs. querying concurrently); query every *other* source's (via `identity()`, task 2.3) `snapshot_at(H)` concurrently; `reach_quorum` over the seed plus all of those responses; insert into `snapshots`, set `latest`, return the agreed height -- [x] 3.6 Implement `latest_snapshot_height(&self) -> Result`: return cached `latest` or call `refresh()` -- [x] 3.7 Implement a private `snapshot_for(&self, height) -> Result`: cache hit on `height` returns immediately; on miss query all sources' `snapshot_at(height)`, `reach_quorum` (verifying the returned height equals the requested one, else `NoQuorumSnapshotForHeight(height)` even if some other height reached quorum), cache, return; a height the quorum cannot attest at all returns `NoQuorumSnapshotForHeight(height)` - -## 4. Default anchor - -Placement revised mid-implementation (see `design.md` D8): the compiled-in constants do -not live in `nym-directory-client` itself, but in `nym-network-defaults::mainnet` - -that crate already hardcodes exactly this shape of thing -(`UPGRADE_MODE_ATTESTER_ED25519_BS58_PUBKEY` / `UPGRADE_MODE_ATTESTATION_URL`), and -`nym-directory-client` was already going to depend on it transitively via -`nym-validator-client`. Quorum is derived from the signer count (a majority) rather -than separately hardcoded, so the list and the threshold cannot drift out of sync. - -- [x] 4.1 Add `DirectoryAttestationSourceConst { api_url: &str, identity_ed25519_bs58: &str }` (compiled-in) and `DirectoryAttestationSource { api_url: String, identity_ed25519_bs58: String }` (owned, for env-sourced values) to `common/network-defaults/src/network.rs`, and a mainnet-only `DIRECTORY_ATTESTATION_SOURCES: &[DirectoryAttestationSourceConst]` (`#[cfg(feature = "network")]`) to `common/network-defaults/src/mainnet.rs`, holding the 2 currently-known real mainnet identity keys/URLs (a commented-out third entry documents the nym-api that cannot be added yet - see the "External prerequisite"). No separate quorum constant (see 4.2). Added `pub fn default_directory_attestation_sources() -> Vec`: reads a `DIRECTORY_ATTESTATION_SOURCES` JSON env var when `env_configured()` (mirroring `NYM_APIS`/`NYM_VPN_APIS`), else falls back to the compiled mainnet list - the `#[cfg(feature = "env")]` block guarding the env path had to be scoped *inside* the always-compiled function (not as an unconditional top-level `use crate::env_configured`/`use crate::var_names`), since `network.rs` compiles under `feature = "network"` alone and those items require `feature = "env"` too; also wired into `mainnet::export_to_env()`/`export_to_env_if_not_set()` so `setup_env()` backfills the var from the compiled default for any real binary that didn't have it in its static `.env` file (verified end-to-end against `envs/mainnet.env` - no manual duplication needed there). -- [x] 4.2 Add `nym-network-defaults` as a dependency of `nym-directory-client`; implement `AttestedTrustAnchor::majority_quorum(signer_count: usize) -> usize` (`signer_count / 2 + 1`, public) and a private `default_trusted_signers()` parsing `default_directory_attestation_sources()`'s bs58 keys into `HashSet` (`#[allow(clippy::expect_used)]` - the workspace denies `expect_used` by default); implement `AttestedTrustAnchor::with_default_anchor(sources, chain_id, directory_contract)` calling `new(sources, default_trusted_signers(), majority_quorum(...), chain_id, directory_contract)` -- [x] 4.3 Unit tests: `majority_quorum` matches simple-majority arithmetic for several signer counts; `with_default_anchor` produces an anchor whose `trusted_signers`/quorum match the compiled-in default; `new(...)` with a caller-supplied set is unaffected by the default - -## 5. DirectoryTrustAnchor impl and re-exports - -- [x] 5.1 Implement `trusted_app_hash(H)`: `snapshot_for(H)` then return its `app_hash` -- [x] 5.2 Implement `trusted_digest(H)`: `snapshot_for(H)` then return `TrustedDigest { height: H, accumulator }` (no ICS23 proof - the quorum attests the accumulator directly) -- [x] 5.3 Expose the snapshot's `node_identities_hash` for a given height via an anchor-specific accessor (`pub async fn trusted_node_identities_hash`, not part of the shared `DirectoryTrustAnchor` trait, so `ProvenTrustAnchor` / `LightClientAnchor` are untouched) -- [x] 5.4 Re-export `AttestedTrustAnchor`, `AttestationSource`, `SignedDigestSnapshot`, `DigestSnapshot` from `src/anchor/mod.rs` -- [x] 5.5 (added) Unit test `directory_trust_anchor_impl_returns_the_attested_values`: `trusted_app_hash`, `trusted_digest`, and `trusted_node_identities_hash` all return values matching a quorum-agreed mock snapshot at a given height - -## 6. Data-source-agnostic whole-directory verification - -- [ ] 6.1 Extract the body of `DirectoryClient::verified_directory` (digest recompute, per-entry authorship attribution) into a function that accepts pre-fetched `records` and `node_identities`, plus the trusted `accumulator` and `node_identities_hash`, and needs no `CosmWasmClient` at all -- [ ] 6.2 Add a node-identity hash recompute check (mirroring `recompute_accumulator`) using the encoder from 1.4, failing closed (`DigestMismatch`-equivalent) on any mismatch -- [ ] 6.3 Make `DirectoryClient::verified_directory` a thin wrapper: fetch records + node identities via `self.client` as today, then call the extracted function - existing RPC-backed callers (any anchor) see no behavior change -- [ ] 6.4 Add a new entry point (free function or method not requiring `C: CosmWasmClient`) that verifies a whole-directory fetch given caller-supplied records + node identities and an anchor whose snapshot carries `node_identities_hash` - errors clearly if the anchor does not provide one (e.g. `NodeIdentitiesHashUnavailable`) rather than skipping authorship verification silently - -## 7. Error handling - -- [ ] 7.1 Add to `DirectoryClientError`: ~~`QuorumNotReached { needed: usize, agreed: usize }`~~, ~~`NoQuorumSnapshotForHeight(u64)`~~, ~~`InvalidQuorumConfig { quorum: usize, signers: usize }`~~ (all three added early, needed by task 3), `NodeIdentitiesHashUnavailable`, and an attestation-transport / decode variant as needed - -## 8. Tests (mock transport) - -- [ ] 8.1 Add a `MockAttestationSource` (in-memory, serves pre-registered latest + per-height signed snapshots + its `identity()`; records call log) - mirror the `MockRpcClient` pattern; helper to build a `SignedDigestSnapshot` from a seeded `KeyPair`. A non-call-logging version of this already exists as `MockSource` in `attested.rs`'s own test module (task 3); task 8 can promote/extend it rather than starting fresh -- [ ] 8.2 Unit test: K distinct trusted signers agreeing on identical values yields a trusted snapshot; `trusted_app_hash` and `trusted_digest` return the attested values -- [ ] 8.3 Unit test: fewer than K valid agreeing signers returns `QuorumNotReached` -- [ ] 8.4 Unit test: a duplicated signer key is counted once (does not reach quorum on its own) -- [ ] 8.5 Unit test: an attestation from an untrusted signer, or with an invalid signature, or with a mismatched chain-id / contract, is ignored (not counted toward quorum) -- [ ] 8.6 Unit test: signers disagreeing on `(app_hash, accumulator, node_identities_hash)` such that no group reaches K is rejected -- [ ] 8.7 Unit test: `refresh()` pins the latest agreed height; a later `trusted_app_hash(H)` for a cached height is served without re-querying sources (call log) -- [ ] 8.8 Unit test: a recent past height within the window is verified via `snapshot_at`; a height the quorum cannot attest returns `NoQuorumSnapshotForHeight` -- [ ] 8.9 Unit test: `new` with `quorum > signers` or `quorum == 0` returns `InvalidQuorumConfig` -- [ ] 8.10 Unit test: whole-directory verification via the decoupled entry point (6.4) succeeds against caller-supplied records + node identities matching the trusted snapshot, and fails closed on a mismatch in either the accumulator or the node-identities hash -- [ ] 8.11 Unit test: the decoupled entry point (6.4) against an anchor without a `node_identities_hash` returns `NodeIdentitiesHashUnavailable` -- [ ] 8.12 Unit test: `DirectoryClient::verified_directory` (RPC-backed path, 6.3) is behaviorally unchanged for `ProvenTrustAnchor` / existing tests - -## 9. Verification - -- [ ] 9.1 `cargo test -p nym-directory-contract-common --lib` passes (new payload + node-identity-hash tests) -- [ ] 9.2 `cargo test -p nym-directory-client --lib` passes (attested anchor tests + decoupled verification tests + existing tests) -- [ ] 9.3 `cargo build -p nym-directory-client` and `cargo build -p nym-directory-client --features light-client` both succeed (attested anchor is not feature-gated and must build in both) diff --git a/openspec/specs/directory-attested-anchor/spec.md b/openspec/specs/directory-attested-anchor/spec.md new file mode 100644 index 00000000000..43f23c43cfc --- /dev/null +++ b/openspec/specs/directory-attested-anchor/spec.md @@ -0,0 +1,122 @@ +# directory-attested-anchor Specification + +## Purpose + +Defines the requirements for `AttestedTrustAnchor`, a `DirectoryTrustAnchor` implementation that establishes the trusted block `app_hash`, directory digest, and node-identity binding from a K-of-N quorum of configured nym-api (Tier-1) identity keys. It replaces both the honest-RPC assumption of `ProvenTrustAnchor` and the checkpoint requirement of `LightClientAnchor` with a different trust root: a snapshot is accepted only when at least K distinct configured signers sign identical `(height, app_hash, accumulator, node_identities_hash)` values. It ships with a small, overridable default trust root, requires no root key and no light-client checkpoint, and lets whole-directory retrieval proceed with no direct chain RPC connection at all. + +## Requirements + +### Requirement: Quorum trust root configured out-of-band, with a default +`AttestedTrustAnchor` SHALL be constructed with a set of trusted nym-api ed25519 identity keys, a quorum threshold `K`, the expected chain-id, and the directory contract address. It SHALL ship with a small, hardcoded default trust root (Nym-SA-owned identity keys and a default `K`) usable with no caller-supplied configuration, and SHALL allow any caller to override that default with its own signer set and threshold. It SHALL NOT require a root key or a light-client checkpoint, and SHALL NOT fetch or self-bootstrap its trusted signer set from any untrusted source. Construction SHALL reject a configuration where `K` is zero or `K` exceeds the number of trusted signers. + +#### Scenario: Valid configuration initialises the anchor +- **WHEN** a caller constructs `AttestedTrustAnchor::new(sources, trusted_signers, quorum, chain_id, contract)` with `1 <= quorum <= trusted_signers.len()` +- **THEN** the anchor stores the signer set, threshold, chain-id, and contract, and makes no network call during construction + +#### Scenario: Default anchor requires no caller-supplied signer set +- **WHEN** a caller constructs the anchor via its default-anchor constructor, supplying only sources, chain-id, and contract +- **THEN** the anchor uses the compiled-in default `trusted_signers` and quorum threshold + +#### Scenario: A caller can override the default trust root +- **WHEN** a caller constructs `AttestedTrustAnchor::new(...)` with its own `trusted_signers` and `quorum`, distinct from the compiled-in default +- **THEN** the anchor uses only the caller-supplied set, and the default is not consulted + +#### Scenario: Degenerate quorum is rejected +- **WHEN** `new` is called with `quorum == 0` or `quorum > trusted_signers.len()` +- **THEN** it returns an `InvalidQuorumConfig` error and no anchor is constructed + +### Requirement: Canonical, replay-resistant attestation format +The bytes a nym-api signs SHALL be produced by a shared canonical encoder (`digest_snapshot_signing_payload`) that binds a domain-separation tag, the chain-id, the contract address, the height, the `app_hash`, the digest `accumulator`, and a hash over the current `NodeId -> ed25519 identity` mapping (`node_identities_hash`), using length-prefixed framing so adjacent variable-length fields cannot be confused. The domain tag SHALL differ from the node-entry signing payload so a snapshot signature can never be interpreted as a node-entry signature. The producer and the client SHALL use the identical encoder for both the signing payload and the `node_identities_hash` itself. + +#### Scenario: Payload is deterministic and field-sensitive +- **WHEN** the encoder is called twice with the same inputs +- **THEN** it returns identical bytes, and any change to chain-id, contract, height, app_hash, accumulator, or node_identities_hash produces different bytes + +#### Scenario: Node-identity hash is deterministic and order-independent +- **WHEN** the node-identity hash encoder is called twice with the same `(NodeId, identity)` pairs presented in different iteration order +- **THEN** it returns identical bytes, and any change to the set of pairs produces a different hash + +#### Scenario: Cross-chain or cross-contract replay is rejected +- **WHEN** a validly signed snapshot carries a chain-id or contract address other than the ones the anchor was configured with +- **THEN** the anchor treats that attestation as invalid and does not count it toward quorum + +### Requirement: Quorum verification of a snapshot +`trusted_app_hash(H)` and `trusted_digest(H)` SHALL accept a snapshot only when at least `K` DISTINCT trusted signers produce valid signatures over identical `(height, app_hash, accumulator, node_identities_hash)` values. An attestation is valid only if its signer is in the configured trusted set, its chain-id and contract match, and its ed25519 signature verifies over the canonical payload. Distinctness SHALL be by signer key, so a repeated signer counts once. If no set of values reaches `K` distinct valid signers, the anchor SHALL return a `QuorumNotReached` error and MUST NOT return an `app_hash`, digest, or node-identities hash. + +#### Scenario: K agreeing distinct signers are accepted +- **WHEN** at least `K` distinct trusted signers return valid attestations over the same `(height, app_hash, accumulator, node_identities_hash)` +- **THEN** the anchor accepts that snapshot and caches it + +#### Scenario: Fewer than K agreeing signers are rejected +- **WHEN** fewer than `K` distinct trusted signers agree on the same values +- **THEN** the anchor returns `QuorumNotReached { needed, agreed }` and returns no trusted value + +#### Scenario: A repeated signer counts once +- **WHEN** the same trusted signer's attestation is presented multiple times +- **THEN** it contributes at most one toward the quorum count + +#### Scenario: Untrusted or invalid attestations are ignored +- **WHEN** an attestation is signed by a key not in the trusted set, carries an invalid signature, or binds a mismatched chain-id or contract +- **THEN** it is excluded from the quorum count rather than causing the whole verification to error + +#### Scenario: Disagreeing signers do not form a quorum +- **WHEN** trusted signers return valid attestations but split across different `(app_hash, accumulator, node_identities_hash)` values so that no single value reaches `K` +- **THEN** the anchor returns `QuorumNotReached` and returns no trusted value + +### Requirement: app_hash, digest, and node-identity hash from the same attestation +`trusted_app_hash(H)`, `trusted_digest(H)`, and the node-identities hash accessor SHALL return values drawn from the SAME quorum-agreed attestation for `H`, so they cannot disagree. `trusted_digest(H)` SHALL return the attested accumulator directly and SHALL NOT require an ICS23 proof, because the quorum attests the digest itself. The node-identities hash SHALL likewise be returned without any additional proof. + +#### Scenario: Digest is returned without an ICS23 proof +- **WHEN** a quorum-agreed snapshot for `H` exists +- **THEN** `trusted_digest(H)` returns its `accumulator` without performing any store-membership proof + +#### Scenario: app_hash, digest, and node-identity hash are drawn from one snapshot +- **WHEN** `trusted_app_hash(H)`, `trusted_digest(H)`, and the node-identities hash accessor are all called for the same `H` +- **THEN** all three are served from the one cached snapshot for `H`, so they cannot disagree + +### Requirement: Latest snapshot discovery and retained-window heights +The anchor SHALL support discovering the quorum-agreed LATEST snapshot (`refresh` / `latest_snapshot_height`) by querying each source's latest attestation and reaching quorum. For a specific height `H`, `trusted_app_hash(H)` / `trusted_digest(H)` SHALL serve a cached snapshot for `H` if present, and otherwise fetch a per-height attestation from the quorum. A height the quorum cannot attest (outside the producers' retained window) SHALL return an error rather than any unverified value. + +#### Scenario: Latest agreed snapshot is discovered and pinned +- **WHEN** `latest_snapshot_height()` (or `refresh()`) is called and a quorum agrees on a latest snapshot +- **THEN** the anchor caches it and returns its height, which the caller uses to drive `verified_directory` + +#### Scenario: A recent past height within the window is verified +- **WHEN** `trusted_app_hash(H)` is called for a height `H` older than the latest but still served by the quorum +- **THEN** the anchor fetches and quorum-verifies the snapshot at `H` and returns its `app_hash` + +#### Scenario: A height outside the retained window is rejected +- **WHEN** `trusted_app_hash(H)` is called for a height no quorum snapshot exists for +- **THEN** the anchor returns `NoQuorumSnapshotForHeight(H)` and no value + +### Requirement: In-memory cache of quorum-agreed snapshots +Quorum-agreed snapshots SHALL be cached in memory within the anchor instance, keyed by height. Repeated calls for the same height within one process lifetime SHALL be served from cache without re-querying the sources. + +#### Scenario: Repeated query uses the cache +- **WHEN** `trusted_app_hash(H)` is called twice for the same `H` in one session +- **THEN** the sources are queried only once; the second call returns from cache + +### Requirement: Transport abstraction +Attestations SHALL be fetched through an `AttestationSource` abstraction (fetch latest / fetch at a height), so the anchor is independent of any particular transport and can be exercised with a mock source. The concrete HTTP transport and the nym-api producer endpoint are out of scope for this capability. + +#### Scenario: Anchor operates against any AttestationSource +- **WHEN** the anchor is constructed with sources implementing `AttestationSource` +- **THEN** all attestation fetching goes through that trait, and a test mock can drive every path + +### Requirement: Available in the default build +`AttestedTrustAnchor` SHALL compile in the default `nym-directory-client` build without any feature flag (it introduces no heavy dependency), and SHALL also compile when the `light-client` feature is enabled. + +#### Scenario: Present without any feature +- **WHEN** `nym-directory-client` is compiled with no extra features +- **THEN** `AttestedTrustAnchor` is available + +### Requirement: Whole-directory verification requires no chain RPC connection +Given a quorum-agreed snapshot for height `H`, directory entries and the `NodeId -> ed25519 identity` mapping fetched from ANY source SHALL be verifiable by local hash recomputation alone (against the attested `accumulator` and `node_identities_hash` respectively), with no chain RPC connection required by the verifying party. This SHALL fail closed: a mismatch in either recomputed hash SHALL be treated as a verification failure, not partial success. + +#### Scenario: Matching data verifies without any chain connection +- **WHEN** directory entries and a node-identity mapping obtained from any untrusted source are checked against an `AttestedTrustAnchor`'s trusted snapshot for `H` +- **THEN** both are accepted once their locally recomputed hashes equal the attested `accumulator` and `node_identities_hash`, with no RPC call made + +#### Scenario: A mismatch in either hash fails closed +- **WHEN** the recomputed accumulator or the recomputed node-identities hash does not match the attested value +- **THEN** verification fails and no directory data is returned, regardless of which of the two hashes disagreed diff --git a/openspec/specs/directory-retrieval-client/spec.md b/openspec/specs/directory-retrieval-client/spec.md index 2176aca3bfb..65f5e8b6a61 100644 --- a/openspec/specs/directory-retrieval-client/spec.md +++ b/openspec/specs/directory-retrieval-client/spec.md @@ -122,3 +122,33 @@ When compiled with the `light-client` feature, the crate SHALL provide `LightCli #### Scenario: ProvenTrustAnchor remains available - **WHEN** `nym-directory-client` is compiled without the `light-client` feature - **THEN** `ProvenTrustAnchor` is available and `LightClientAnchor` is not + +### Requirement: Attested anchor for keyless bootstrap +The crate SHALL provide `AttestedTrustAnchor` as a `DirectoryTrustAnchor` implementation that establishes the trusted `app_hash`, directory digest, and node-identity binding from a K-of-N quorum of configured nym-api identity keys, requiring no root key and no light-client checkpoint. It SHALL ship with a small, overridable default trust root. Deployments that cannot yet provision a light-client checkpoint MAY use it; `ProvenTrustAnchor` and `LightClientAnchor` remain available and unchanged. + +#### Scenario: AttestedTrustAnchor satisfies DirectoryTrustAnchor +- **WHEN** `DirectoryClient` is constructed with an `AttestedTrustAnchor` +- **THEN** `verified_directory` and `verified_node_entry` / `verified_curated_entry` behave identically to the other anchors, with the sole difference that `trusted_app_hash` and `trusted_digest` are sourced from a signed-snapshot quorum instead of an RPC header or a light-client verification + +#### Scenario: Whole-directory recompute still guards the attested digest +- **WHEN** `verified_directory(H)` runs against an `AttestedTrustAnchor` and the locally recomputed accumulator over the fetched entries does not equal the quorum-attested accumulator +- **THEN** the client returns a `DigestMismatch` error and no entries, so a false attested digest fails closed rather than being accepted + +#### Scenario: Single-entry reads remain ICS23-proven +- **WHEN** `verified_node_entry` or `verified_curated_entry` is called against an `AttestedTrustAnchor` +- **THEN** the entry is still verified by an ICS23 membership proof against the quorum-attested `app_hash`, preserving the trustless per-entry path + +### Requirement: Whole-directory retrieval without a chain RPC connection +When the configured anchor's trusted snapshot carries a node-identities hash (today, only `AttestedTrustAnchor`), the crate SHALL provide a way to verify a whole-directory fetch - entries and node identities alike - using only locally recomputed hashes, with no `CosmWasmClient` / chain RPC connection required. The existing RPC-backed `DirectoryClient::verified_directory` path SHALL remain available and behaviorally unchanged for all anchors. + +#### Scenario: Directory verified from data sourced without any chain connection +- **WHEN** a caller supplies directory entries and a node-identity mapping obtained from any source (not a chain RPC connection) alongside an `AttestedTrustAnchor`'s trusted snapshot for height `H` +- **THEN** the crate verifies both the entries (against the accumulator) and the node-identity mapping (against the node-identities hash) by local recompute alone, and returns the same `VerifiedDirectory` shape as the RPC-backed path + +#### Scenario: Decoupled verification fails closed without a node-identities hash +- **WHEN** the decoupled verification path is used with an anchor whose trusted snapshot does not carry a node-identities hash (e.g. `ProvenTrustAnchor`, `LightClientAnchor`) +- **THEN** it returns an error rather than skipping authorship verification silently + +#### Scenario: Existing RPC-backed retrieval is unaffected +- **WHEN** `DirectoryClient::verified_directory` is called as before, with any anchor +- **THEN** it fetches entries and node identities via the client's own chain connection exactly as it did previously, with no observable behavior change