Skip to content

Verify ZKSAR attestations before they buy trust#423

Open
DROOdotFOO wants to merge 1 commit into
masterfrom
payments/zksar-verify-trust-337
Open

Verify ZKSAR attestations before they buy trust#423
DROOdotFOO wants to merge 1 commit into
masterfrom
payments/zksar-verify-trust-337

Conversation

@DROOdotFOO

Copy link
Copy Markdown
Owner

What

Closes #337. Wires Raxol.Payments.Zksar.verify_batch/2 into the live trust
pipeline 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 Router credited unverified, self-asserted
attestations.

The vulnerability (HIGH)

A caller -- or a prompt-injected agent assembling Router opts -- could pass:

Router.select(attestations: [%{type: :compliance, valid: true},
                             %{type: :non_membership, valid: true}])

with no signature and no issuer, and obtain max trust score -> :sovereign /
:shielded tier -> the lowest fee tier. Router.maybe_compute_trust_score/1
called TrustScore.aggregate/1 directly on caller maps, and PrivacyTier gated
tiers on a caller-settable :valid boolean. The signature verification added in
the 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_batch has zero
production callers, every live/E2E/property flow resolves trust via an explicit
trust_score (never attestations), and only self-asserted test fixtures
exercised 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/1 now verifies attestations up front and
    replaces :attestations with only the verified subset -- so nothing unverified
    reaches EITHER the score aggregation OR the PrivacyTier tier-requirement
    check. This runs on every path (computed-score and explicit-trust_score).
  • Issuers are pinned via config :raxol_payments, :zksar_allowed_issuers, [...].
    Unset (the default) => no attestation can be verified => none buys trust.
  • Callers now supply raw, signed proofs (type_code/signature/issuer/...);
    verify_batch requires the signature recover to an allowlisted issuer.
  • PrivacyTier documents that it consumes pre-verified attestations (Router is
    the verification choke point); a self-asserted %{valid: true} map is not a
    proof.

An explicit :trust_score integer is a separate, caller-owned hint (clamped),
unchanged by this PR.

Tests (RED-proven; real ExSecp256k1 signatures, no mocks)

router_test.exs reworked (now async: false -- it sets the allowlist app env):

  • Positive: real signed proofs from the allowlisted issuer compute a score and
    route to :xochi; a verified compliance+non_membership pair satisfies the
    sovereign requirement; a verified proof that misses a tier's required types
    still forces a downgrade.
  • Fail closed (each was RED before the fix): a self-asserted proof (no signature)
    buys nothing (:x402 / score 0 / :public); a proof from a non-allowlisted
    issuer buys nothing; no configured allowlist means no attestation buys trust;
    unverified attestations are dropped, not credited.

privacy_tier_test / trust_score_test are unchanged -- they exercise the
pre-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, 0
    failures.
  • Scoped mix format + --check-formatted on changed files -> clean.
  • MIX_ENV=test mix compile --force -> 0 warnings from the changed files.
  • Confirmed the self-asserted attestation fixtures fail RED before the fix,
    then pass GREEN after (fail-closed).

Not in this PR (issue point 5, lower severity)

  • Binding a per-use nonce / chain id / intent id into attestation_digest/1 to
    prevent 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.
  • Enforcing low-s canonicalization in recover_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 is
green.

@github-actions

Copy link
Copy Markdown

Unified Regression Test Results

Workflow: Unified Regression Testing
Event: pull_request
SHA: 25691e9

Performance Results

Found 1 performance result(s)

Memory Results

Found 3 memory result(s)

Targets

  • Parser: <3μs average
  • Render: <1ms average
  • Memory: <3MB per session

@DROOdotFOO

Copy link
Copy Markdown
Owner Author

Waiting until completed on the Xochi end and we wire providers.

@DROOdotFOO DROOdotFOO left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

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_score channel, which the
    PR's own threat model treats as caller/prompt-injection controlled. Passing
    Router.settlement_for(trust_score: 100) with no :attestations returns
    :sovereign / :shielded (lowest 15bps fee) -- reproduced. The attestation-requirement
    gate (sovereign needs compliance + non_membership) is silently skipped because
    extract_attestation_types/1 returns nil when :attestations is 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 (assert trust_score: 100,
    omit attestations) buys the same max trust AND skips the type-requirement downgrade.
    Scenario: a prompt-injected agent assembling Router opts sets trust_score: 100 and gets
    sovereign fees + shielded settlement with zero proofs. Fix: under the hostile-caller model,
    an explicit :trust_score must 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/2 never checks proof.subject against 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-expiry non_membership+compliance proofs and inherits A's
    trust/fee tier until expiry, across sessions and recipients. This is a replayable-attestation
    bypass. Fix: bind the signed digest to subject == 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/2 and
    aggregate/1 do 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 honest compliance+non_membership pair 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-s not 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/1 only validates
    subject/issuer/signature, but attestation_digest/1 also requires :payload and
    :issued_at. A proof with a future expires_at and an allowlisted issuer but no
    :payload passes structure/expiry/issuer, then check_signature -> attestation_digest
    raises FunctionClauseError (reproduced). Nothing in Router rescues it, so
    Router.select/1 crashes 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_code presence in check_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_batch returns
    {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_signature defaults 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: false structural-only until reconciled).

LOW

  • [New Hire] LOW privacy_tier.ex:114-132 + moduledoc -- "Verified" is a bare
    valid: true field 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 reaches from_trust_score without going through Router re-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 at zksar.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.

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.

Wire ZKSAR attestation verification into the trust pipeline

1 participant