Het geldpad verharden: transactiebeveiliging in KVR-settlement

Drie echte kwetsbaarheidsklassen — replay van betaalhandtekeningen, ongeauthenticeerd beloningen slaan en race-double-spends — gevonden, in tests uitgebuit en gesloten in de settlement-dienst van de gateway.

In een DePIN is het geldpad precies zo vijandig als het rekenpad: elk endpoint dat KVR bijschrijft wordt vroeg of laat afgetast door iemand die KVR wil zonder het werk te doen. Een beveiligingsronde over de settlement-dienst van de gateway — het proces dat on-chain betalingen verifieert en stakes, node-beloningen en inferentiekosten bijschrijft — vond en sloot drie echte kwetsbaarheidsklassen. Elk werd vóór de fix gedemonstreerd met een exploit-achtige test en erna opnieuw geverifieerd.

Het vertrouwensmodel

Verifieer on-chain feiten, geen clientbeweringen

Kvasirs bewaringsmodel laat de sleutels bij de gebruikers: wallets ondertekenen transacties, Solana registreert ze, en de taak van de settlement-dienst is verifiëren wat er werkelijk on-chain gebeurde voordat hij een saldo aanraakt. Betalingen volgen *quote → payment → inference*, waarbij elke verbruikte transactiehandtekening in een eenmalig usedSignatures-register wordt genoteerd zodat ze nooit twee keer kan worden aangeboden. Dat maakt de settlement-dienst het knelpunt — en de regel die hij nooit mag breken: alleen bijschrijven wat de chain bewijst, nooit wat de client beweert. De dienst houdt op devnet wel degelijk tegoeden aan: gestakete KVR en vooraf betaalde credits staan in zijn treasury en worden in zijn grootboek bijgehouden totdat een on-chain stakingprogramma wordt uitgebracht.

A settlement vault guarded by three locks: sender binding, trusted reporter, and a serialization gate
Fix #1 · afzenderbinding

Bind de betaling aan de betaler

Solana-handtekeningen zijn openbaar. Het stake-verificatiepad controleerde dat de vault het verwachte KVR *ontving* — maar nooit *wie het stuurde*. Een aanvaller kon op devnet de KVR→vault-overdracht van een slachtoffer afwachten en dan {owner: aanvaller, signature: die van het slachtoffer} indienen: de vault-ontvangstcheck slaagde, de hoofdsom werd aan de aanvaller bijgeschreven, en één unstake later was het geld van hem. Directe diefstal, met niets meer dan een block explorer.

De fix: het KVR moet zijn afgeschreven van tokenrekeningen waarvan de eigenaar de bijgeschreven owner is.

verifyStakeTransfer(signature, owner, amount):
  delta(vault)  >= amount            # vault actually received it (old check)
  Σ debits from token accounts
    whose owner == credited owner    # NEW — sender binding
                >= amount            # summed across that owner's accounts
  # inference path (no owner): bound by private requestId
  # + one-shot usedSignatures instead
Fix #2 · vertrouwde rapporteur

Beloningen alleen uit geauthenticeerde bronnen

De node-belonings-endpoints sloegen claimbaar KVR uit zelfgerapporteerde invoer: POST /api/node/contribution schreef de units bij die de client beweerde — units: 1e9 plus één claim-aanroep kon de vault leegtrekken — en register/heartbeat geloofden zelfverklaarde hub-/gatewayrollen (uurlijkse infra-beloningen) en prestatiescores (beloningsvermenigvuldigers). De fix zet elke beloningsbeïnvloedende bewering achter een vertrouwde rapporteur: alleen het M2M-servicetoken van de contributiepoll van de hub, of een geauthenticeerde admin, mag units, infra-rollen of prestatieniveaus beweren — afgedwongen zelfs in open LAN-modus, omdat dit KVR slaat. De tokenvergelijking is constant-tijd, en wallet↔node-koppeling blijft vrij; ze kan alleen niet meer haar eigen beloningen beweren.

Fix #3 · settlement-serialisatie

Eén schrijver per saldo

De settlement-status was een lock-vrij read-modify-write, en elke geldoperatie *awaitet* halverwege een on-chain uitbetaling of verificatie — en geeft de event loop op met een verouderd saldo in de hand. Twee gelijktijdige claims konden hetzelfde openstaande saldo van 100 KVR lezen en het beide uitbetalen. Niet theoretisch: de exploit-test toonde drie gelijktijdige claims die 300 uitbetaalden voor een saldo van 100.

Asynchrone serialisatie per sleutel: geldoperaties met dezelfde sleutel draaien strikt na elkaar.

withLock(key, fn)         # per-key promise chain, self-cleaning map
  stake / unstake / claim  → keyed by owner
  inference settlement     → keyed by requestId
inside the lock:
  usedSignatures check + credit   # no same-signature double-credit
  pay out FIRST, then debit       # failed payout leaves balance intact
Verdediging in de diepte

Waar elke laag nu staat

LaagMechanisme
IdentiteitSIWS-wallet-handtekeninglogin over een server-nonce + TOTP-2FA + eenmalige back-upcodes
TransportWallet-afgeleide node-tokens bij shard-downloads; build-fingerprint-overeenstemming op de relay
BetalingAfzenderbinding op stake-overdrachten; eenmalige usedSignatures; privé requestId bij inferentie
SettlementPer-sleutel-locks rond elke saldoschrijving; eerst uitbetalen, dan afschrijven; idempotent opnieuw indienen
RapportageBeloningsbeïnvloedende feiten alleen van het M2M-servicetoken of admin, constant-tijd vergeleken
BewaringNon-custodial wallets — de dienst kan alleen bewegen wat de vault bevat, nooit gebruikerssleutels
Gemeten, niet aangenomen

Elke fix draagt zijn eigen exploit-test

  • Het opnieuw afspelen van de overdrachtshandtekening van een slachtoffer onder de wallet van een aanvaller wordt nu geweigerd ("not sent by owner"); legitieme stakes, over-claims en inferentiebetalingen gedragen zich ongewijzigd.
  • Drie gelijktijdige claims op één saldo betalen precies één keer; het opnieuw indienen van een al betaalde inferentie geeft idempotent hetzelfde resultaat terug.
  • Zelfbeweerde units, hub-/gatewayrollen en prestatieniveaus van ongeauthenticeerde clients verplaatsen geen enkele lamport aan beloningen meer.

De rode draad van alle drie de fixes is één principe, drie keer toegepast: de chain is de bron van waarheid, de dienst is een verificateur, en elk saldo heeft precies één schrijver. De settlement-dienst draait nog op Solana devnet — precies waar je deze klassen wilt vinden, uitbuiten en repareren voordat mainnet de inzet verhoogt.