Verify ZKSAR attestations before they buy trust#423
Conversation
Unified Regression Test ResultsWorkflow: Unified Regression Testing Performance ResultsFound 1 performance result(s) Memory ResultsFound 3 memory result(s) Targets
|
|
Waiting until completed on the Xochi end and we wire providers. |
DROOdotFOO
left a comment
There was a problem hiding this comment.
Adversarial Review (automated)
Scope: the ZKSAR verify -> trust-score -> privacy-tier/fee pipeline wired by this PR
(Router, Zksar, Zksar.TrustScore, PrivacyTier). Closing the attestation-forgery
path (raw self-asserted %{valid: true} maps buying trust) is a real, correct improvement
and the fail-closed default is sound. The findings below are the paths that survive it. All
three "HIGH/MEDIUM" claims were reproduced empirically against the PR branch.
HIGH
-
[New Hire / Saboteur] HIGH
router.ex:152-153+privacy_tier.ex:134-- The new
verification choke point is bypassable by the sibling:trust_scorechannel, which the
PR's own threat model treats as caller/prompt-injection controlled. Passing
Router.settlement_for(trust_score: 100)with no:attestationsreturns
:sovereign/:shielded(lowest 15bps fee) -- reproduced. The attestation-requirement
gate (sovereignneedscompliance+non_membership) is silently skipped because
extract_attestation_types/1returnsnilwhen:attestationsis absent, and
maybe_downgrade_for_attestations(tier, nil)returns the tier unchanged. So the hard path
(forge a signed attestation) is now closed while the trivial path (asserttrust_score: 100,
omit attestations) buys the same max trust AND skips the type-requirement downgrade.
Scenario: a prompt-injected agent assemblingRouteropts setstrust_score: 100and gets
sovereign fees + shielded settlement with zero proofs. Fix: under the hostile-caller model,
an explicit:trust_scoremust not exceed the attestation-derived ceiling (or tier
requirements must apply to score-only paths, i.e. treat absent attestations as the empty
set for gating, not as "no gate"). -
[Security Auditor / Saboteur] HIGH
zksar.ex:116-136(no subject binding) +
schemas.ex:157-164-- A verified attestation is bound to nothing the caller must possess.
verify/2never checksproof.subjectagainst the paying wallet/recipient/session, and the
signed digest (attestation_digest/1) carries no per-use nonce/intent id (PR defers nonce;
subject binding is not even acknowledged). Attestations are transmitted to Xochi inside
SettleRequest(schemas.ex:157), i.e. observable on the wire. Scenario: agent B replays
agent A's valid, far-future-expirynon_membership+complianceproofs and inherits A's
trust/fee tier until expiry, across sessions and recipients. This is a replayable-attestation
bypass. Fix: bind the signed digest tosubject== authenticated caller (and, with the
deferred nonce work, to intent/chain), and reject proofs whose subject is not the caller.
MEDIUM
-
[Saboteur] MEDIUM
zksar.ex:145-153+trust_score.ex:44-53--verify_batch/2and
aggregate/1do not de-duplicate, so the diminishing-returns curve is defeated by copies.
Each duplicate of one valid signed proof recovers to the allowlisted issuer and verifies
independently. Reproduced: an honestcompliance+non_membershippair aggregates to 43
(:stealth); duplicating each proof once aggregates to 75 (:sovereign) with both required
types present, so no downgrade -- a holder of two legitimate attestations jumps two tiers to
the lowest fee / deepest privacy. Fix: de-dupe verified proofs by (type, subject, signature)
before aggregation. Signature malleability (low-snot enforced, PR-acknowledged) is a
second dedup-evasion vector for the same attestation. -
[Saboteur / Security Auditor] MEDIUM
zksar.ex:247-253(check_structure) +
zksar.ex:181(attestation_digest/1) +router.ex:181-- Verification does not fail
closed on a malformed proof; it raises.check_structure/1only validates
subject/issuer/signature, butattestation_digest/1also requires:payloadand
:issued_at. A proof with a futureexpires_atand an allowlistedissuerbut no
:payloadpasses structure/expiry/issuer, thencheck_signature -> attestation_digest
raisesFunctionClauseError(reproduced). Nothing inRouterrescues it, so
Router.select/1crashes on caller-controlled input -- a DoS, and a "fail-open by
exception" shape rather than a clean{:error, :malformed}. Fix: validate:payload/
:issued_at/:type_codepresence incheck_structure(return:malformed); optionally
guard the batch reduce. -
[Security Auditor] MEDIUM
router.ex:181+zksar.ex:19-27-- Verification-failure
telemetry is absent and the module's own precondition is violated.verify_batchreturns
{verified, _errors}and the errors are discarded, so an operator cannot distinguish a
forgery attempt from every real Xochi attestation being rejected. That distinction matters
precisely because the module warns (lines 19-27) the digest scheme is provisional and "must
still be reconciled with Xochi ... before enabling signature checks against production data"
-- yet this PR enables production signature checks (verify_signaturedefaults true) against
that unconfirmed digest. Fail-closed means no forgery, but if the operator pins the real
Xochi issuer and the digest scheme differs, all genuine attestations silently drop to public
tier with no signal. Fix: emit[:raxol, :payments, :zksar, :verify_failed]telemetry with
the reason, and gate production enablement on a confirmed on-chain vector (or keep it
verify_signature: falsestructural-only until reconciled).
LOW
- [New Hire] LOW
privacy_tier.ex:114-132+ moduledoc -- "Verified" is a bare
valid: truefield on a plain map, not a distinct struct/type.PrivacyTier.from_trust_score/2
is public and its safety against a caller-built%{type: :compliance, valid: true}rests
entirely on the moduledoc convention "Router is the choke point," not on the type system.
Any code path that reachesfrom_trust_scorewithout going throughRouterre-opens the
original bug. Fix: make verified proofs a tagged struct (e.g.%Zksar.Verified{}) so the
compiler, not prose, enforces "pre-verified."
Cross-persona overlaps
- Replay/binding: Saboteur (reuse) and Security Auditor (domain separation) converge on the
same root atzksar.ex-- verified proofs bound to neither caller nor per-use nonce.
Promoted to HIGH. - "Not observably fail-closed": the dropped
_errors(telemetry MEDIUM) and the
crash-on-malformed (DoS MEDIUM) share the theme that failure is silent or explosive rather
than a clean, logged rejection.
Verdict: BLOCK
The attestation-forgery fix is genuine and should land, but two caller-controlled paths
survive it under the PR's own hostile-caller threat model: an explicit trust_score: 100
buys sovereign tier while skipping the attestation-requirement gate entirely (HIGH, reproduced),
and verified attestations are bound to neither caller nor nonce, making any observed proof
replayable until expiry (HIGH). Address the trust_score ceiling + gate-on-score-only paths
and add verification-failure telemetry before merge; track subject/nonce binding and proof
de-duplication with the already-deferred replay work.
Adversarial reviewer: Saboteur / New Hire / Security Auditor personas.
What
Closes #337. Wires
Raxol.Payments.Zksar.verify_batch/2into the live trustpipeline so attestations are cryptographically verified against a pinned issuer
allowlist BEFORE they buy any trust. Previously the verification code existed but
had zero production callers, so
Routercredited unverified, self-assertedattestations.
The vulnerability (HIGH)
A caller -- or a prompt-injected agent assembling
Routeropts -- could pass:with no signature and no issuer, and obtain max trust score ->
:sovereign/:shieldedtier -> the lowest fee tier.Router.maybe_compute_trust_score/1called
TrustScore.aggregate/1directly on caller maps, andPrivacyTiergatedtiers on a caller-settable
:validboolean. The signature verification added inthe origin-pull hardening pass never executed.
Why it's safe to wire now (the deferral was stale)
The issue was pinned "deferred until a tested ZKSAR issuer exists." Verified
against the code, that rationale no longer holds:
Zksar.verify_batchhas zeroproduction callers, every live/E2E/property flow resolves trust via an explicit
trust_score(never attestations), and only self-asserted test fixturesexercised the attestation path. Wiring verification is therefore pure hardening
with no functional regression -- and since no issuer is pinned by default, the
fail-closed default is strictly safer than crediting fakes.
The fix (fail closed)
Router.maybe_compute_trust_score/1now verifies attestations up front andreplaces
:attestationswith only the verified subset -- so nothing unverifiedreaches EITHER the score aggregation OR the
PrivacyTiertier-requirementcheck. This runs on every path (computed-score and explicit-
trust_score).config :raxol_payments, :zksar_allowed_issuers, [...].Unset (the default) => no attestation can be verified => none buys trust.
type_code/signature/issuer/...);verify_batchrequires the signature recover to an allowlisted issuer.PrivacyTierdocuments that it consumes pre-verified attestations (Router isthe verification choke point); a self-asserted
%{valid: true}map is not aproof.
An explicit
:trust_scoreinteger is a separate, caller-owned hint (clamped),unchanged by this PR.
Tests (RED-proven; real ExSecp256k1 signatures, no mocks)
router_test.exsreworked (nowasync: false-- it sets the allowlist app env):route to
:xochi; a verified compliance+non_membership pair satisfies thesovereign requirement; a verified proof that misses a tier's required types
still forces a downgrade.
buys nothing (
:x402/ score 0 /:public); a proof from a non-allowlistedissuer buys nothing; no configured allowlist means no attestation buys trust;
unverified attestations are dropped, not credited.
privacy_tier_test/trust_score_testare unchanged -- they exercise thepre-verified contract (feeding verified-shaped maps directly), which is exactly
what Router now guarantees.
Manual testing
cd packages/raxol_payments && MIX_ENV=test mix test-> 1013 passed, 0failures.
mix format+--check-formattedon changed files -> clean.MIX_ENV=test mix compile --force-> 0 warnings from the changed files.then pass GREEN after (fail-closed).
Not in this PR (issue point 5, lower severity)
attestation_digest/1toprevent cross-settlement replay of a valid attestation until expiry. This
changes the signed message and must be coordinated with the issuer
(cross-repo), so it is a separate change.
scanonicalization inrecover_signer.CI note
Root CI does not build
raxol_payments-- the package suite(
cd packages/raxol_payments && MIX_ENV=test mix test) is the gate, and it isgreen.