Files
arbiter/IMPLEMENTATION.md
hdbg 6386510f52
Some checks failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-test Pipeline was successful
merge: evm into main
2026-03-12 16:29:00 +01:00

8.0 KiB

Implementation Details

This document covers concrete technology choices and dependencies. For the architectural design, see ARCHITECTURE.md.


Client Connection Flow

New Client Approval

When a client whose public key is not yet in the database connects, all connected user agents are asked to approve the connection. The first agent to respond determines the outcome; remaining requests are cancelled via a watch channel.

flowchart TD
    A([Client connects]) --> B[Receive AuthChallengeRequest]
    B --> C{pubkey in DB?}

    C -- yes --> D[Read nonce\nIncrement nonce in DB]
    D --> G

    C -- no --> E[Ask all UserAgents:\nClientConnectionRequest]
    E --> F{First response}
    F -- denied --> Z([Reject connection])
    F -- approved --> F2[Cancel remaining\nUserAgent requests]
    F2 --> F3[INSERT client\nnonce = 1]
    F3 --> G[Send AuthChallenge\nwith nonce]

    G --> H[Receive AuthChallengeSolution]
    H --> I{Signature valid?}
    I -- no --> Z
    I -- yes --> J([Session started])

Known Issue: Concurrent Registration Race (TOCTOU)

Two connections presenting the same previously-unknown public key can race through the approval flow simultaneously:

  1. Both check the DB → neither is registered.
  2. Both request approval from user agents → both receive approval.
  3. Both INSERT the client record → the second insert silently overwrites the first, resetting the nonce.

This means the first connection's nonce is invalidated by the second, causing its challenge verification to fail. A fix requires either serialising new-client registration (e.g. an in-memory lock keyed on pubkey) or replacing the separate check + insert with an INSERT OR IGNORE / upsert guarded by a unique constraint on public_key.

Nonce Semantics

The program_client.nonce column stores the next usable nonce — i.e. it is always one ahead of the nonce last issued in a challenge.

  • New client: inserted with nonce = 1; the first challenge is issued with nonce = 0.
  • Existing client: the current DB value is read and used as the challenge nonce, then immediately incremented within the same exclusive transaction, preventing replay.

Cryptography

Authentication

  • Signature scheme: ed25519

Encryption at Rest

  • Scheme: Symmetric AEAD — currently XChaCha20-Poly1305
  • Version tracking: Each aead_encrypted database entry carries a scheme field denoting the version, enabling transparent migration on unseal

Server Identity

  • Transport: TLS with a self-signed certificate
  • Key type: Generated on first run; long-term (no rotation mechanism yet)

Communication

  • Protocol: gRPC with Protocol Buffers
  • Server identity distribution: ServerInfo protobuf struct containing the TLS public key fingerprint
  • Future consideration: grpc-web lacks bidirectional stream support, so a browser-based wallet may require protojson over WebSocket

EVM Policy Engine

Overview

The EVM engine classifies incoming transactions, enforces grant constraints, and records executions. It is the sole path through which a wallet key is used for signing.

The central abstraction is the Policy trait. Each implementation handles one semantic transaction category and owns its own database tables for grant storage and transaction logging.

Transaction Evaluation Flow

Engine::evaluate_transaction runs the following steps in order:

  1. Classify — Each registered policy's analyze(context) inspects the transaction fields (chain, to, value, calldata). The first one returning Some(meaning) wins. If none match, the transaction is rejected as UnsupportedTransactionType.
  2. Find grantPolicy::try_find_grant queries for a non-revoked grant covering this wallet, client, chain, and target address.
  3. Check shared constraintscheck_shared_constraints runs in the engine before any policy-specific logic. It enforces the validity window, gas fee caps, and transaction count rate limit (see below).
  4. EvaluatePolicy::evaluate checks the decoded meaning against the grant's policy-specific constraints and returns any violations.
  5. Record — If RunKind::Execution and there are no violations, the engine writes to evm_transaction_log and calls Policy::record_transaction for any policy-specific logging (e.g., token transfer volume).

Policy Trait

Method Purpose
analyze Pure — classifies a transaction into a typed Meaning, or None if this policy doesn't apply
evaluate Checks the Meaning against a Grant; returns a list of EvalViolations
create_grant Inserts policy-specific rows; returns the specific grant ID
try_find_grant Finds a matching non-revoked grant for the given EvalContext
find_all_grants Returns all non-revoked grants (used for listing)
record_transaction Persists policy-specific data after execution

analyze and evaluate are intentionally separate: classification is pure and cheap, while evaluation may involve DB queries (e.g., fetching past transfer volume).

Registered Policies

EtherTransfer — plain ETH transfers (empty calldata)

  • Grant requires: allowlist of recipient addresses + one volumetric rate limit (max ETH over a time window)
  • Violations: recipient not in allowlist, cumulative ETH volume exceeded

TokenTransfer — ERC-20 transfer(address,uint256) calls

  • Recognised by ABI-decoding the transfer(address,uint256) selector against a static registry of known token contracts (arbiter_tokens_registry)
  • Grant requires: token contract address, optional recipient restriction, zero or more volumetric rate limits
  • Violations: recipient mismatch, any volumetric limit exceeded

Grant Model

Every grant has two layers:

  • Shared (evm_basic_grant) — wallet, chain, validity period, gas fee caps, transaction count rate limit. One row per grant regardless of type.
  • Specific — policy-owned tables (evm_ether_transfer_grant, evm_token_transfer_grant, etc.) holding type-specific configuration.

find_all_grants uses a #[diesel::auto_type] base join between the specific and shared tables, then batch-loads related rows (targets, volume limits) in two additional queries to avoid N+1.

The engine exposes list_all_grants which collects across all policy types into Vec<Grant<SpecificGrant>> via a blanket From<Grant<S>> for Grant<SpecificGrant> conversion.

Shared Constraints (enforced by the engine)

These are checked centrally in check_shared_constraints before policy evaluation:

Constraint Fields Behaviour
Validity window valid_from, valid_until Emits InvalidTime if current time is outside the range
Gas fee cap max_gas_fee_per_gas, max_priority_fee_per_gas Emits GasLimitExceeded if either cap is breached
Tx count rate limit rate_limit (count + window) Counts rows in evm_transaction_log within the window; emits RateLimitExceeded if at or above the limit

Known Limitations

  • Only EIP-1559 transactions are supported. Legacy and EIP-2930 types are rejected outright.
  • No opaque-calldata (unknown contract) grant type. The architecture describes a category for unrecognised contracts, but no policy implements it yet. Any transaction that is not a plain ETH transfer or a known ERC-20 transfer is unconditionally rejected.
  • Token registry is static. Tokens are recognised only if they appear in the hard-coded arbiter_tokens_registry crate. There is no mechanism to register additional contracts at runtime.
  • Nonce management is not implemented. The architecture lists nonce deduplication as a core responsibility, but no nonce tracking or enforcement exists yet.

Memory Protection

The unsealed root key must be held in a hardened memory cell resistant to dumps, page swaps, and hibernation.

  • Current: Using the memsafe crate as an interim solution
  • Planned: Custom implementation based on mlock (Unix) and VirtualProtect (Windows)