Skip to content

feat(antd): opt-in signed-quote exposure + stateless VerifyQuotes - #242

Merged
Nic-dorman merged 4 commits into
mainfrom
feat/signed-quote-exposure
Sep 16, 2026
Merged

Nic-dorman merged 4 commits into
mainfrom
feat/signed-quote-exposure

Conversation

@Nic-dorman

@Nic-dorman Nic-dorman commented Aug 24, 2026

Copy link
Copy Markdown
Member

Summary

Hosted-payments fraud control (Linear V2-854): in hosted mode the party that pays is not the party that collected the quotes — the payer receives payments[] triples from a customer-controlled instance and must not trust them (a fabricated triple could name any address and amount). This PR gives the payer the means to verify a batch offline before settling it, in two halves:

1. Opt-in signed-quote exposure (include_signed_quotes)

All three prepare endpoints (upload / data / chunk, REST + gRPC) accept the flag. Wave-batch responses then carry signed_quotes[] — one entry per payments[] triple with the full signed PaymentQuote and its ADR-0004 commitment sidecar as opaque base64(msgpack) bytes (only antd ever parses them):

  • Entries are restricted to the payment intent's paid quote set — the network quotes the whole close group (~7 peers/chunk) but single-quote payments (V2-619) pay only the median, so signed_quotes[] is guaranteed 1:1 with payments[].
  • Sidecars (ant-core's compacted, non-index-aligned vector) are matched to quotes by commitment_hash(sidecar) == quote.commitment_pin at emit time, so each entry is self-contained.
  • Default-off: ~5–6 KB per quote + up to 8 KB per sidecar; existing consumers see zero change.
  • Merkle prepares never populate it — candidate exposure is blocked upstream (private candidate pools; resolved commitments deliberately discarded, regression-test-enforced) and is specced on V2-934.

2. Stateless offline verification (POST /v1/verify/quotes + gRPC VerifyService)

A pure function of the request — no network, wallet, or session state — run by "the party about to pay" on its own antd. Per entry (port of ant-core's client-side quote_commitment_binding_is_valid, with the peer identity derived from the quote's own pub_key):

  1. quote_hash == hash(signed_quote) — the triple is tied to a concrete quote
  2. ML-DSA-65 signature over the paid fields
  3. Paid-fields equality: rewards_address exact, amount == 3× signed price (the single-node payment multiplier — mirrored as a documented local const since ant-core's is pub(crate))
  4. ADR-0004 resolve-before-pay binding: sidecar present for pinned quotes, signed under the quote's key, commitment_hash == pin, count equality, and price == calculate_price(count) by exact recomputation (baseline == calculate_price(0))

Verdicts carry the fields caller-side policy needs (timestamp, content XorName, price, rewards address, key count, pinned). Policy itself (expiry, replay ledgers, chunk-set equality, count caps) deliberately stays caller-side. CPU-bound work runs on spawn_blocking; 1024-entry cap.

Dependencies

  • New direct dep ant-protocol = "=2.3.2" — pinned exactly to the already-locked transitive version so PaymentQuote/StorageCommitment can't fork in the graph. All PQC verification routed through its re-exports (no new saorsa deps).
  • xor_name as dev-dependency (test quote construction).
  • ant-core pin unchanged (ant-cli-v0.3.3). Rebased 2026-09-16 onto main (ant-core 0.8.0 / ant-cli-v0.3.5): the exact ant-protocol pin moves =2.3.2=2.3.4 to match main's lock. Checked both sides: ant-protocol 2.3.2→2.3.4 touches only its own Cargo manifests, and ant-core 0.6.0→0.8.0 touches no payment/quote code, so the mirrored SINGLE_NODE_PAYMENT_MULTIPLIER and the ported binding check are unaffected. No ant-client changes involved.

Rebase note (2026-09-16)

main now commits antd-rust's tonic-build output (src/generated/antd.v1.rs, 6895d29) instead of compiling ../antd/proto at build time, so the new proto surface (prepare include_signed_quotes / signed_quotes, VerifyService) is regenerated and committed here, and verify.proto joins the drift test's list. The earlier duplicate #[allow(clippy::result_large_err)] commit is dropped — main carries it.

Re-verified on Linux after the rebase: antd fmt/clippy/doc/test (79 tests), antd-rust fmt/clippy/doc/test (82 + drift), antd-go vet/mod verify/test/race.

Testing

  • 58 Rust tests (8 new: genuinely-signed ML-DSA quotes + commitments, tamper cases, builder association); antd-go build/vet/tests incl. new httptest coverage; clippy + fmt clean.
  • Live devnet smoke (24-node local devnet + anvil): prepare with the flag → 1:1 signed quotes → VerifyQuotes passes on real network quotes; inflated amount, redirected payee, and fabricated quote_hash each rejected with the named rule. Two real-network corrections came out of this (paid-set filter; 3× multiplier) — both covered by unit tests now.

Docs

docs/external-signer-flow.md (exposure + verification recipe + policy split), antd/openapi.yaml (new path + 5 schemas), proto comments throughout.

Release note

Version bump left out per house convention (separate chore/bump-* PR at release time — docs reference the feature as "antd 0.13.0").

🤖 Generated with Claude Code

Nic-dorman and others added 4 commits September 16, 2026 10:43
…ST + gRPC)

Hosted payments fraud control (V2-854 work items 1 + 1b): in hosted mode the
party that pays is not the party that collected the quotes, so the payer must
be able to verify a payment batch offline before settling it.

Exposure: `include_signed_quotes` on the three prepare endpoints. Wave-batch
responses carry `signed_quotes[]` — one entry per payments[] triple with the
full signed PaymentQuote and its ADR-0004 commitment sidecar as opaque
base64(msgpack) bytes (sidecars matched to quotes by commitment_hash == pin;
entries restricted to the payment intent's paid quote set, since the network
quotes the whole close group but single-quote payments pay only the median).
Default-off; existing consumers see no change. Merkle candidate exposure is
blocked upstream (private candidate pools, commitments deliberately
discarded) and rides V2-934.

Verification: POST /v1/verify/quotes + gRPC VerifyService. Stateless offline
port of ant-core's quote_commitment_binding_is_valid, run by the party about
to pay on its own antd: quote-hash recomputation, ML-DSA-65 signature,
paid-fields equality (amount == 3x the signed price — the single-node payment
multiplier), and the ADR-0004 binding with exact on-curve pricing. Verdicts
carry the extracted fields caller-side policy needs (timestamp, content,
price, rewards address, key count, pinned).

New direct dep ant-protocol = "=2.3.2", pinned to the locked transitive
version; all PQC verification routed through its re-exports.

Verified against a live local devnet: real-quote batch verifies; inflated
amount, redirected payee, and fabricated quote_hash are all rejected with
named rules.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Go client surface for the V2-854 daemon additions: PrepareOptions with
IncludeSignedQuotes (PrepareUploadWithOptions / PrepareDataUploadWithOptions /
PrepareChunkUploadWithOptions), SignedQuoteEntry parsing on both prepare
results, and VerifyQuotes with per-entry verdict models. Quote and sidecar
blobs stay opaque — the client relays them, only antd parses them.

Generated protobuf refresh for the new fields + verify.proto service.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The generated PrepareChunkRequest/PrepareFileUploadRequest/
PrepareDataUploadRequest structs gained include_signed_quotes and the
prepare responses gained signed_quotes; antd-rust's exhaustive struct
literals must name them. Requests default to false (no behaviour change);
the antd-rust client surface for the new options/endpoint can follow
separately if wanted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… protos; add verify.proto to the drift list

main now ships antd-rust's tonic-build output in src/generated/ (6895d29)
instead of compiling ../antd/proto at build time, so this branch's new proto
surface (include_signed_quotes / signed_quotes on the prepare messages, the
VerifyService) has to be regenerated and committed:

  ANTD_REGEN_PROTO=1 cargo test --test proto_drift

verify.proto joins the drift test's PROTOS list so the committed module
tracks the daemon's full API. The branch's earlier duplicate
`#[allow(clippy::result_large_err)]` commit is dropped — main carries it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@Nic-dorman
Nic-dorman force-pushed the feat/signed-quote-exposure branch from 6fa8332 to 4689218 Compare September 16, 2026 09:55

@dirvine dirvine left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review of hosted-payments fraud control (V2-854) — APPROVE (head 4689218)

Reviewed by a 6-seat independent panel (crypto/protocol ×3, 1:1-invariant, proto/generated-consistency, transport/DoS), cross-checked against pinned ant-core ant-cli-v0.3.5 / ant-protocol =2.3.4. No blockers; no dissent.

Verified sound:

  • binding_is_valid (signed_quotes.rs:231–305) is a faithful one-for-one port of ant-core quote_commitment_binding_is_valid — shape, MAX_COMMITMENT_KEY_COUNT cap, forced on-curve calculate_price, sidecar presence/size/parse, signature, commitment_hash==pin, key-count equality. The compute_address(quote.pub_key) identity substitution is sound: network PeerId == BLAKE3(node key) == BLAKE3(quote.pub_key) == sender_peer_id in real flows.
  • Payment verification: 3× multiplier == SINGLE_NODE_PAYMENT_MULTIPLIER=3; genuine prepares pass with no false rejection; paid-set is 1:1 by shared source across all six prepare endpoints; no underpay/redirect/overflow path (checked U256, decimal-only parse).
  • Panic-free on untrusted input — every parse failure becomes a per-entry valid:false or a 400/InvalidArgument; empty batch is valid:false; ML-DSA work on spawn_blocking.
  • Wire-compatible: new proto fields default-false, backward compatible both directions; REST and gRPC carry byte-identical quotes cross-verifiable over either transport.

Warnings (non-blocking, recommend follow-up):

  1. SDK wrapper lag — antd-rust client hardcodes include_signed_quotes: false and never surfaces signed_quotes (REST or gRPC); antd-go GrpcClient neither sets the flag nor copies GetSignedQuotes() and has no VerifyQuotes wrapper. Proto surface is correct; consumers must drop to raw generated proto types. Please fix in this PR or a tracked follow-up.
  2. Caps enforced after deserialisation — 1024-entry cap (rest/verify.rs:22→24) and per-entry byte caps run after body parse/base64-decode. Bounded by the 100 MB daemon-wide DefaultBodyLimit (not unbounded), but a route-specific tighter limit plus a pre-decode len > MAX*4/3 check would make the cap bound the deserialisation it targets.
  3. gRPC ceiling ≠ REST ceiling — tonic default 4 MiB decode limit effectively caps gRPC at ~600–750 real entries vs REST 1024. Harmless for the stated 256-chunk case; raise the limit explicitly or document the asymmetry.
  4. No auth/rate-limit — consistent with antd's uniform loopback-trust posture (default binds 127.0.0.1), not a regression from this PR; note in docs for non-loopback exposure.

Threat-model caveat (by design, documented at docs/external-signer-flow.md:410–412): the verifier proves internal consistency, not signer legitimacy. A malicious customer-controlled instance can mint its own keypairs and fabricate a passing batch, redirecting payment to its own address (up to ~1M-key count). Gateway policy layer must enforce expiry + replay ledger, match verdicts 1:1 against payments[] by quote_hash, compare content to the chunks being paid for, and refuse merkle batches outright (merkle prepares expose no verifiable evidence — deferred to V2-934). Treat a green verdict as tamper-evident-and-on-curve, not network-vouched.

@Nic-dorman
Nic-dorman merged commit 83e6819 into main Sep 16, 2026
9 checks passed
Nic-dorman added a commit that referenced this pull request Sep 16, 2026
…flight; map it in all 11 gRPC SDKs

REST's prepare-upload response has reported `total_chunks` and
`already_stored_count` since antd 0.10.0; the gRPC PrepareUploadResponse
never did, so gRPC external-signer consumers could not reconcile "why am I
paying for K chunks when the file is M chunks". The gRPC SDK mappers either
defaulted the two model fields to 0 (rust, java) or silently left them at
the model default (everyone else).

- upload.proto: `uint64 total_chunks = 13`, `uint64 already_stored_count = 14`
  (12 is signed_quotes from #242, 11 merkle_batches); `build_grpc_prepare_response`
  populates both on the wave-batch and merkle branches from the same
  `PreparedUpload` fields REST uses.
- 11 gRPC SDKs (rust, go, csharp, java, kotlin, dart, elixir, ruby, swift,
  cpp, py) map the two fields onto their existing PrepareUploadResult model;
  each in-process gRPC test's PrepareFileUpload mock now sets 3/1 and the
  prepare-upload test asserts them. Kotlin's vendored protos synced
  (copyProtos task output).
- antd-rust generated code regenerated (ANTD_REGEN_PROTO=1).
- antd-dart/tool/generate_proto.sh: missing line continuation after
  upload.proto made the shell try to execute wallet.proto.

Regenerated stubs for go/py/ruby/dart/elixir/swift follow in a separate commit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Nic-dorman added a commit that referenced this pull request Sep 16, 2026
…, swift

PrepareUploadResponse gains total_chunks (13) / already_stored_count (14).
Regeneration also brings these SDKs stubs in line with proto changes that
had landed without a regen: the 0.12 health fields (write_ready,
connected_peers, routing_table_size, rebootstrap_threshold,
last_store_ok_secs_ago) in health.proto, and #242 signed-quote additions
in chunks.proto / common.proto / upload.proto. Go regenerated for
upload.proto only to avoid protoc-gen-go version-stamp churn. New stub
files (events, verify) that were never committed are deliberately not
added; alias-only churn in dart wallet.pbgrpc.dart reverted.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants