From 436d9c449bda5462ccde412308cf820a413c57fd Mon Sep 17 00:00:00 2001 From: webiumsk Date: Mon, 7 Sep 2026 08:16:26 +0200 Subject: [PATCH 1/2] fix(security): payee attestation judges only new payments, re-confirms nodes with a canary Production incident 2026-09-07 04:45 UTC: the daily settlement reconcile (boltz:reconcile-settlements -> syncRecent) re-read last week's settled invoices for every store and the attestation, hooked into syncInvoice, judged those historical payments against the allow-list learned today. Dozens of merchants got "payment received by an unknown wallet" alerts for old invoices (Blink lnd1/lnd2 vs. Blink's current private node, Boltz CLN vs. Boltz Mini, a coinos store's old Blink-era invoices). - The ledger now attests a payment only when it records it for the first time (wasRecentlyCreated), and PayeeAttestationService::attestPayment ignores payments received before the wallet was (re)connected or before the allow-list was learned - history is neither judged nor learned from. - Providers run several nodes: on an unknown node the service first asks the connected wallet for up to three fresh canary invoices; if it signs with that node now, the node is added (reason canary_reconfirm) instead of raising an incident. - wallet-connections:reset-payee-incidents {--since} {--purge-messages} {--dry-run} closes false incidents and deletes their security messages (sent e-mails cannot be recalled); audited as payee_incident_reset. - Docs (EN + SK) describe both rules; tests cover history cutoff, canary re-confirmation, no re-judging on resync, and the reset command. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01TStPoTJxcruEvTBb7RLcM5 --- app/Console/Commands/ResetPayeeIncidents.php | 65 +++++++++ .../Admin/WalletChangeLogController.php | 1 + .../Boltz/SettlementLedgerService.php | 35 +++-- .../PayeeAttestationService.php | 134 ++++++++++++++---- docs/user/en/wallet-security-alerts.md | 2 +- docs/user/sk/wallet-security-alerts.md | 2 +- resources/js/locales/cs.json | 1 + resources/js/locales/de.json | 1 + resources/js/locales/en.json | 1 + resources/js/locales/es.json | 1 + resources/js/locales/sk.json | 1 + tests/Feature/PayeeAttestationTest.php | 89 +++++++++++- 12 files changed, 293 insertions(+), 40 deletions(-) create mode 100644 app/Console/Commands/ResetPayeeIncidents.php diff --git a/app/Console/Commands/ResetPayeeIncidents.php b/app/Console/Commands/ResetPayeeIncidents.php new file mode 100644 index 00000000..609e69d6 --- /dev/null +++ b/app/Console/Commands/ResetPayeeIncidents.php @@ -0,0 +1,65 @@ +option('since') ? Carbon::parse((string) $this->option('since')) : null; + $dry = (bool) $this->option('dry-run'); + + $rows = WalletConnection::query() + ->whereNotNull('payee_mismatch_at') + ->when($since, fn ($q) => $q->where('payee_mismatch_at', '>=', $since)) + ->get(); + + foreach ($rows as $connection) { + $this->line(sprintf('incident store %s since %s node %s%s', $connection->store_id, $connection->payee_mismatch_at, $connection->payee_mismatch_details['pubkey'] ?? '?', $dry ? ' (dry-run)' : '')); + if ($dry) { + continue; + } + $details = $connection->payee_mismatch_details; + $connection->forceFill(['payee_mismatch_at' => null, 'payee_mismatch_details' => null])->save(); + AuditLog::log('wallet_connection.payee_incident_reset', 'wallet_connection', $connection->id, [ + 'store_id' => $connection->store_id, + 'reset_details' => $details, + ], null); + } + + $deleted = 0; + if ($this->option('purge-messages')) { + $messages = UserMessage::query() + ->where('type', 'security') + ->where(function ($q) { + $q->where('title', 'like', 'Payment received by an unknown wallet%') + ->orWhere('title', 'like', 'Payee mismatch:%'); + }) + ->when($since, fn ($q) => $q->where('created_at', '>=', $since)); + $deleted = $dry ? $messages->count() : $messages->delete(); + } + + $this->info(sprintf('%s %d incident(s), %d security message(s)%s', $dry ? 'Would reset' : 'Reset', $rows->count(), $deleted, $dry ? ' (dry-run)' : '')); + + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/Admin/WalletChangeLogController.php b/app/Http/Controllers/Admin/WalletChangeLogController.php index 23531ed0..2732c7d2 100644 --- a/app/Http/Controllers/Admin/WalletChangeLogController.php +++ b/app/Http/Controllers/Admin/WalletChangeLogController.php @@ -34,6 +34,7 @@ class WalletChangeLogController extends Controller 'wallet_connection.payee_learned', 'wallet_connection.payee_mismatch', 'wallet_connection.payee_accepted', + 'wallet_connection.payee_incident_reset', 'store.cashu_fallback_configured', ]; diff --git a/app/Services/Boltz/SettlementLedgerService.php b/app/Services/Boltz/SettlementLedgerService.php index 9ac54aab..7a161fcb 100644 --- a/app/Services/Boltz/SettlementLedgerService.php +++ b/app/Services/Boltz/SettlementLedgerService.php @@ -52,14 +52,6 @@ public function syncInvoice(Store $store, string $invoiceId, bool $forgetCache = $invoice = $this->invoiceService->getInvoice($btcpayStoreId, $invoiceId, $userApiKey); $methods = $this->invoiceService->getInvoicePaymentMethods($btcpayStoreId, $invoiceId, $userApiKey); - // Security: who signed the Lightning invoices this store got paid on - // (PayeeAttestationService). Never lets a failure break the ledger. - try { - app(PayeeAttestationService::class)->attestInvoice($store, $invoiceId, $methods); - } catch (\Throwable $e) { - Log::error('Payee attestation failed', ['store_id' => $store->id, 'invoice_id' => $invoiceId, 'error' => $e->getMessage()]); - } - $count = 0; foreach ($methods as $method) { if (! is_array($method)) { @@ -144,7 +136,9 @@ protected function syncPaymentMethod(Store $store, string $invoiceId, array $inv $estimate = $this->estimateNetSettlement($category, $grossSats); - StoreSettlement::updateOrCreate( + $paidAt = isset($payment['receivedDate']) ? Carbon::parse($payment['receivedDate']) : null; + $destination = isset($payment['destination']) ? (string) $payment['destination'] : null; + $row = StoreSettlement::updateOrCreate( [ 'store_id' => $store->id, 'btcpay_invoice_id' => $invoiceId, @@ -153,9 +147,9 @@ protected function syncPaymentMethod(Store $store, string $invoiceId, array $inv ], [ 'category' => $category, - 'destination' => isset($payment['destination']) ? (string) $payment['destination'] : null, + 'destination' => $destination, 'payment_status' => isset($payment['status']) ? (string) $payment['status'] : null, - 'paid_at' => isset($payment['receivedDate']) ? Carbon::parse($payment['receivedDate']) : null, + 'paid_at' => $paidAt, 'gross_sats' => $grossSats, 'invoice_currency' => isset($invoice['currency']) ? strtoupper((string) $invoice['currency']) : null, 'invoice_amount' => isset($invoice['amount']) && is_numeric($invoice['amount']) ? (string) $invoice['amount'] : null, @@ -165,6 +159,25 @@ protected function syncPaymentMethod(Store $store, string $invoiceId, array $inv ] ); $count++; + + // Security: who signed the Lightning invoice this payment settled + // on (PayeeAttestationService). Only payments the ledger sees for + // the first time - the daily reconcile re-reads history and must + // not judge old invoices against today's wallet. Never lets a + // failure break the ledger. + if ($row->wasRecentlyCreated && in_array($methodId, PayeeAttestationService::LIGHTNING_METHODS, true)) { + try { + app(PayeeAttestationService::class)->attestPayment( + $store, + $invoiceId, + $methodId, + $destination ?: (isset($method['destination']) ? (string) $method['destination'] : null), + $paidAt, + ); + } catch (\Throwable $e) { + Log::error('Payee attestation failed', ['store_id' => $store->id, 'invoice_id' => $invoiceId, 'error' => $e->getMessage()]); + } + } } return $count; diff --git a/app/Services/WalletSecurity/PayeeAttestationService.php b/app/Services/WalletSecurity/PayeeAttestationService.php index ba267a4b..011e9c91 100644 --- a/app/Services/WalletSecurity/PayeeAttestationService.php +++ b/app/Services/WalletSecurity/PayeeAttestationService.php @@ -7,6 +7,8 @@ use App\Models\User; use App\Models\WalletConnection; use App\Services\BtcPay\InvoiceService; +use Carbon\CarbonInterface; +use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Log; /** @@ -28,6 +30,9 @@ class PayeeAttestationService public const LIGHTNING_METHODS = ['BTC-LN', 'BTC-LNURL']; + /** Multi-node providers may answer from a different node per invoice. */ + public const CANARY_RECONFIRM_ATTEMPTS = 3; + public function __construct( protected InvoiceService $invoices, protected WalletSecurityNotifier $notifier, @@ -41,10 +46,29 @@ public function __construct( public function learn(WalletConnection $connection, ?User $by = null, string $reason = 'connected'): bool { $store = $connection->store; - $owner = $store instanceof Store ? $store->user : null; - if (! $store instanceof Store || ! $owner instanceof User || ! filled($owner->btcpay_api_key)) { + if (! $store instanceof Store) { return false; } + $payee = $this->canaryPayee($store, $reason); + if ($payee === null) { + return false; + } + + $this->setAllowlist($connection, [$payee], 'canary', $by, $reason); + + return true; + } + + /** + * Node id that signs an invoice the store hands out RIGHT NOW: create a + * canary invoice, read its Lightning destination, archive it. + */ + public function canaryPayee(Store $store, string $reason = 'probe'): ?string + { + $owner = $store->user; + if (! $owner instanceof User || ! filled($owner->btcpay_api_key)) { + return null; + } $apiKey = (string) $owner->btcpay_api_key; $btcpayStoreId = (string) $store->btcpay_store_id; @@ -64,14 +88,13 @@ public function learn(WalletConnection $connection, ?User $by = null, string $re $methods = $this->invoices->getInvoicePaymentMethods($btcpayStoreId, $invoiceId, $apiKey); } catch (\Throwable $e) { Log::warning('Payee canary skipped', [ - 'connection_id' => $connection->id, 'store_id' => $store->id, 'reason' => $reason, 'error' => $e->getMessage(), ]); $this->archiveQuietly($btcpayStoreId, $invoiceId, $apiKey); - return false; + return null; } $payee = null; @@ -84,42 +107,72 @@ public function learn(WalletConnection $connection, ?User $by = null, string $re $this->archiveQuietly($btcpayStoreId, $invoiceId, $apiKey); if ($payee === null) { - Log::info('Payee canary produced no Lightning invoice', [ - 'connection_id' => $connection->id, - 'store_id' => $store->id, - ]); - - return false; + Log::info('Payee canary produced no Lightning invoice', ['store_id' => $store->id, 'reason' => $reason]); } - $this->setAllowlist($connection, [$payee], 'canary', $by, $reason); - - return true; + return $payee; } /** - * Check every settled Lightning payment of an invoice (called from the - * settlement ledger sync with the payment methods it already fetched). + * Check every Lightning payment of an invoice payload (manual / admin use; + * the ledger calls attestPayment() per newly recorded payment instead). * * @param list $methods Greenfield invoice payment-methods payload * @return array bolt11 => outcome */ public function attestInvoice(Store $store, string $invoiceId, array $methods): array + { + $results = []; + foreach ($methods as $method) { + if (! is_array($method)) { + continue; + } + $id = strtoupper((string) ($method['paymentMethodId'] ?? $method['paymentMethod'] ?? '')); + if (! in_array($id, self::LIGHTNING_METHODS, true)) { + continue; + } + $payments = is_array($method['payments'] ?? null) ? $method['payments'] : []; + foreach ($payments as $payment) { + if (! is_array($payment)) { + continue; + } + $bolt11 = $payment['destination'] ?? ($method['destination'] ?? null); + if (! is_string($bolt11)) { + continue; + } + $paidAt = isset($payment['receivedDate']) ? Carbon::parse((string) $payment['receivedDate']) : null; + $results[$bolt11] = $this->attestPayment($store, $invoiceId, $id, $bolt11, $paidAt); + } + } + + return $results; + } + + /** + * One settled Lightning payment. Payments received before the wallet was + * (re)connected or before the allow-list was learned are history and are + * neither judged nor learned from. + * + * @return 'ok'|'learned'|'mismatch'|'unparsed'|'skipped'|'historical' + */ + public function attestPayment(Store $store, string $invoiceId, string $methodId, ?string $bolt11, ?CarbonInterface $paidAt): string { $connection = $store->walletConnection; if (! $connection instanceof WalletConnection || $connection->status !== 'connected') { - return []; + return 'skipped'; } - - $results = []; - foreach (self::lightningDestinations($methods, requirePayments: true) as $method => $bolt11) { - $results[$bolt11] = $this->attestBolt11($connection, $bolt11, [ - 'invoice_id' => $invoiceId, - 'method' => preg_replace('/#\d+$/', '', (string) $method), - ]); + if ($bolt11 === null || ! str_starts_with(strtolower($bolt11), 'ln')) { + return 'skipped'; + } + $since = $connection->secret_updated_at ?? $connection->created_at; + if ($connection->payee_learned_at && (! $since || $connection->payee_learned_at->gt($since))) { + $since = $connection->payee_learned_at; + } + if ($paidAt !== null && $since !== null && $paidAt->lt($since)) { + return 'historical'; } - return $results; + return $this->attestBolt11($connection, $bolt11, ['invoice_id' => $invoiceId, 'method' => $methodId]); } /** @@ -153,6 +206,20 @@ public function attestBolt11(WalletConnection $connection, string $bolt11, array } $store = $connection->store; + if (! $store instanceof Store) { + return 'skipped'; + } + + // Providers run several nodes (Blink: lnd1/lnd2/..., Boltz: LND + CLN) + // and move between them. Before raising an incident, ask the connected + // wallet for fresh invoices: if it signs with this node right now, the + // payment is consistent with the wallet Satflux connected. + if ($this->canaryConfirms($store, $payee)) { + $this->setAllowlist($connection, [...$allowed, $payee], $connection->payee_learn_source ?? 'canary', null, 'canary_reconfirm', $context); + + return 'learned'; + } + $details = [ 'pubkey' => $payee, 'invoice_id' => $context['invoice_id'] ?? null, @@ -170,7 +237,7 @@ public function attestBolt11(WalletConnection $connection, string $bolt11, array ->update(['payee_mismatch_at' => now()]) === 1; $connection->refresh(); - if ($first && $store instanceof Store) { + if ($first) { AuditLog::log('wallet_connection.payee_mismatch', 'wallet_connection', $connection->id, [ 'store_id' => $store->id, ...$details, @@ -182,6 +249,22 @@ public function attestBolt11(WalletConnection $connection, string $bolt11, array return 'mismatch'; } + /** Up to CANARY_RECONFIRM_ATTEMPTS fresh invoices: does the wallet sign with $payee now? */ + protected function canaryConfirms(Store $store, string $payee): bool + { + for ($i = 0; $i < self::CANARY_RECONFIRM_ATTEMPTS; $i++) { + $current = $this->canaryPayee($store, 'reconfirm'); + if ($current === null) { + return false; + } + if ($current === $payee) { + return true; + } + } + + return false; + } + /** Admin accepts a node after investigating: add it to the allow-list and close the incident. */ public function accept(WalletConnection $connection, string $pubkey, User $admin): void { @@ -212,6 +295,7 @@ public function accept(WalletConnection $connection, string $pubkey, User $admin */ protected function setAllowlist(WalletConnection $connection, array $pubkeys, string $source, ?User $by, string $reason, array $context = []): void { + $pubkeys = array_values(array_unique($pubkeys)); $connection->forceFill([ 'payee_pubkeys' => $pubkeys, 'payee_learn_source' => $source, diff --git a/docs/user/en/wallet-security-alerts.md b/docs/user/en/wallet-security-alerts.md index 0ea79e24..f68cfc99 100644 --- a/docs/user/en/wallet-security-alerts.md +++ b/docs/user/en/wallet-security-alerts.md @@ -39,7 +39,7 @@ When a difference is detected: Every Lightning invoice is signed by the node that receives the payment. When Satflux connects your wallet it asks the payment server for a tiny test invoice (it is never paid and is archived immediately), reads the signing node from it and remembers it as the node of your wallet. If the test invoice cannot be read, the node behind the first paid invoice is remembered instead. -From then on every settled Lightning payment is checked: the node that signed its invoice must be the node of your wallet. A payment signed by any other node raises a security message and an email that name the invoice and the node, and the Wallet connection page shows a red warning. This check does not depend on what the payment server reports about its configuration - it looks at the invoices that were actually paid. If an invoice cannot be decoded or checked, the failure is logged and the payment is still recorded; such payments are not verified. +From then on every new Lightning payment is checked: the node that signed its invoice must be a node of your wallet. Payments received before the wallet was connected or before the node was learned are history and are not judged. Wallet providers often run several nodes, so when a payment comes from an unknown node Satflux first asks the payment server for fresh test invoices; if your wallet signs with that node right now, the node is added to your wallet's nodes and nothing is raised. Only a node your wallet does not sign with raises a security message and an email that name the invoice and the node, and the Wallet connection page shows a red warning. This check does not depend on what the payment server reports about its configuration - it looks at the invoices that were actually paid. If an invoice cannot be decoded or checked, the failure is logged and the payment is still recorded; such payments are not verified. If you deliberately moved your wallet to another provider, reconnecting it through Satflux records the new node. Only a Satflux administrator can accept a node or relearn it after investigating the incident with you. diff --git a/docs/user/sk/wallet-security-alerts.md b/docs/user/sk/wallet-security-alerts.md index 7f216e74..67189749 100644 --- a/docs/user/sk/wallet-security-alerts.md +++ b/docs/user/sk/wallet-security-alerts.md @@ -39,7 +39,7 @@ Keď sa zistí rozdiel: Každú Lightning faktúru podpisuje uzol, ktorý platbu prijíma. Pri pripojení peňaženky si Satflux od platobného servera vypýta malú testovaciu faktúru (nikdy sa neplatí a hneď sa archivuje), prečíta z nej podpisujúci uzol a zapamätá si ho ako uzol vašej peňaženky. Ak sa testovacia faktúra nedá prečítať, zapamätá si uzol z prvej zaplatenej faktúry. -Odvtedy sa kontroluje každá vysporiadaná Lightning platba: uzol, ktorý podpísal jej faktúru, musí byť uzol vašej peňaženky. Platba podpísaná iným uzlom vyvolá bezpečnostnú správu a e-mail s číslom faktúry a uzlom a na stránke Pripojenie peňaženky sa zobrazí červené varovanie. Táto kontrola nezávisí od toho, čo platobný server tvrdí o svojej konfigurácii - pozerá sa na faktúry, ktoré boli naozaj zaplatené. Ak sa faktúra nedá dekódovať alebo skontrolovať, zlyhanie sa zaloguje a platba sa aj tak zaznamená; také platby nie sú overené. +Odvtedy sa kontroluje každá nová Lightning platba: uzol, ktorý podpísal jej faktúru, musí byť jedným z uzlov vašej peňaženky. Platby prijaté pred pripojením peňaženky alebo pred naučením uzla sú história a neposudzujú sa. Poskytovatelia peňaženiek často prevádzkujú viac uzlov, preto pri platbe z neznámeho uzla si Satflux najprv vypýta od platobného servera čerstvé testovacie faktúry; ak vaša peňaženka práve teraz podpisuje týmto uzlom, uzol sa pridá k uzlom vašej peňaženky a nič sa nehlási. Až uzol, ktorým vaša peňaženka nepodpisuje, vyvolá bezpečnostnú správu a e-mail s číslom faktúry a uzlom a na stránke Pripojenie peňaženky sa zobrazí červené varovanie. Táto kontrola nezávisí od toho, čo platobný server tvrdí o svojej konfigurácii - pozerá sa na faktúry, ktoré boli naozaj zaplatené. Ak sa faktúra nedá dekódovať alebo skontrolovať, zlyhanie sa zaloguje a platba sa aj tak zaznamená; také platby nie sú overené. Ak ste peňaženku úmyselne presunuli k inému poskytovateľovi, nové pripojenie cez Satflux zapíše nový uzol. Prijať uzol alebo ho znova naučiť môže po prešetrení incidentu s vami len administrátor Satfluxu. diff --git a/resources/js/locales/cs.json b/resources/js/locales/cs.json index acd09204..35c0a1bf 100644 --- a/resources/js/locales/cs.json +++ b/resources/js/locales/cs.json @@ -4096,6 +4096,7 @@ "wallet_connection_payee_learned": "Uzel příjemce naučen", "wallet_connection_payee_mismatch": "NESHODA příjemce", "wallet_connection_payee_accepted": "Uzel příjemce přijat adminem", + "wallet_connection_payee_incident_reset": "Incident příjemce zrušen", "store_cashu_fallback_configured": "Cashu fallback nastaven", "wallet_connection_config_rebaselined_by_admin": "Základ přijat adminem" } diff --git a/resources/js/locales/de.json b/resources/js/locales/de.json index 69aca947..023d1e1e 100644 --- a/resources/js/locales/de.json +++ b/resources/js/locales/de.json @@ -4096,6 +4096,7 @@ "wallet_connection_payee_learned": "Empfänger-Node gelernt", "wallet_connection_payee_mismatch": "EMPFÄNGER-Abweichung", "wallet_connection_payee_accepted": "Empfänger-Node vom Admin akzeptiert", + "wallet_connection_payee_incident_reset": "Empfänger-Vorfall zurückgesetzt", "store_cashu_fallback_configured": "Cashu-Fallback konfiguriert", "wallet_connection_config_rebaselined_by_admin": "Basis vom Admin übernommen" } diff --git a/resources/js/locales/en.json b/resources/js/locales/en.json index f1e92930..d9bc7c02 100644 --- a/resources/js/locales/en.json +++ b/resources/js/locales/en.json @@ -4096,6 +4096,7 @@ "wallet_connection_payee_learned": "Payee node learned", "wallet_connection_payee_mismatch": "PAYEE mismatch", "wallet_connection_payee_accepted": "Payee node accepted by admin", + "wallet_connection_payee_incident_reset": "Payee incident reset", "store_cashu_fallback_configured": "Cashu fallback configured", "wallet_connection_config_rebaselined_by_admin": "Baseline accepted by admin" } diff --git a/resources/js/locales/es.json b/resources/js/locales/es.json index 64b37534..858a5962 100644 --- a/resources/js/locales/es.json +++ b/resources/js/locales/es.json @@ -2108,6 +2108,7 @@ "wallet_connection_payee_learned": "Nodo receptor aprendido", "wallet_connection_payee_mismatch": "DISCREPANCIA de receptor", "wallet_connection_payee_accepted": "Nodo receptor aceptado por el admin", + "wallet_connection_payee_incident_reset": "Incidente de receptor restablecido", "store_cashu_fallback_configured": "Respaldo Cashu configurado", "wallet_connection_config_rebaselined_by_admin": "Base aceptada por el admin" } diff --git a/resources/js/locales/sk.json b/resources/js/locales/sk.json index ba46a621..02573dff 100644 --- a/resources/js/locales/sk.json +++ b/resources/js/locales/sk.json @@ -4100,6 +4100,7 @@ "wallet_connection_payee_learned": "Uzol príjemcu naučený", "wallet_connection_payee_mismatch": "NEZHODA príjemcu", "wallet_connection_payee_accepted": "Uzol príjemcu prijatý adminom", + "wallet_connection_payee_incident_reset": "Incident príjemcu zrušený", "store_cashu_fallback_configured": "Cashu fallback nastavený", "wallet_connection_config_rebaselined_by_admin": "Základ prijatý adminom" } diff --git a/tests/Feature/PayeeAttestationTest.php b/tests/Feature/PayeeAttestationTest.php index 3afdc567..20df58b0 100644 --- a/tests/Feature/PayeeAttestationTest.php +++ b/tests/Feature/PayeeAttestationTest.php @@ -32,6 +32,12 @@ class PayeeAttestationTest extends TestCase /** @var list BOLT11s reported as settled payments on invoice "paid-1". */ private array $paidInvoices = [Bolt11Test::SPEC_DONATION]; + /** receivedDate of those payments (default: just now, i.e. after any baseline in the test). */ + private ?string $paidAt = null; + + /** Distinct payment ids per sync so the ledger sees each payment once per test step. */ + private int $paymentSerial = 0; + private bool $faked = false; /** @var list */ @@ -65,7 +71,7 @@ private function fakeBtcPay(): void 'paymentMethodId' => 'BTC-LN', 'destination' => $this->paidInvoices[0], 'rate' => '60000', - 'payments' => array_map(fn ($b) => ['id' => 'p'.md5($b), 'destination' => $b, 'value' => '0.00001', 'status' => 'Settled', 'receivedDate' => now()->toIso8601String()], $this->paidInvoices), + 'payments' => array_map(fn ($b) => ['id' => 'p'.$this->paymentSerial.md5($b), 'destination' => $b, 'value' => '0.00001', 'status' => 'Settled', 'receivedDate' => $this->paidAt ?? now()->addSecond()->toIso8601String()], $this->paidInvoices), ], ], 200); } @@ -159,10 +165,15 @@ public function a_payment_signed_by_another_node_raises_a_security_incident_once $this->assertDatabaseHas('user_messages', ['user_id' => $admin->id, 'type' => 'security']); Notification::assertSentTo($user, WalletPayeeMismatchNotification::class); - // Same invoice synced again (webhook retry): no second incident. + // Same invoice synced again (webhook retry, daily reconcile): the + // payment is already in the ledger, nothing is re-judged. app(SettlementLedgerService::class)->syncInvoice($store, 'paid-1'); $this->assertSame(1, AuditLog::where('action', 'wallet_connection.payee_mismatch')->count()); $this->assertSame(1, UserMessage::where('user_id', $user->id)->where('type', 'security')->count()); + // A NEW payment to the foreign node while the incident is open: still one incident. + $this->paymentSerial++; + app(SettlementLedgerService::class)->syncInvoice($store, 'paid-1'); + $this->assertSame(1, AuditLog::where('action', 'wallet_connection.payee_mismatch')->count()); // Payments to the known node keep passing while the incident is open. $this->paidInvoices = [Bolt11Test::SPEC_DONATION]; @@ -173,6 +184,80 @@ public function a_payment_signed_by_another_node_raises_a_security_incident_once $this->assertNotNull($connection->fresh()->payee_mismatch_at); } + #[Test] + public function historical_payments_are_neither_judged_nor_learned_from(): void + { + Notification::fake(); + $this->fakeBtcPay(); + [$user, $store, $connection] = $this->connectedStore(); + app(WalletConfigIntegrityService::class)->baseline($connection, $user); + + // The daily reconcile re-reads last week's invoices, paid when the store used another wallet. + $this->paidInvoices = [Bolt11Test::OTHER_INVOICE]; + $this->paidAt = now()->subDays(7)->toIso8601String(); + app(SettlementLedgerService::class)->syncInvoice($store, 'paid-1'); + + $this->assertNull($connection->fresh()->payee_mismatch_at); + $this->assertSame(0, AuditLog::where('action', 'wallet_connection.payee_mismatch')->count()); + $this->assertDatabaseCount('store_settlements', 1); + + // Without any allow-list an old payment must not become the trusted node either. + $connection->forceFill(['payee_pubkeys' => null, 'payee_learned_at' => null, 'payee_learn_source' => null])->save(); + $this->paymentSerial++; + app(SettlementLedgerService::class)->syncInvoice($store, 'paid-1'); + $this->assertNull($connection->fresh()->payee_pubkeys); + } + + #[Test] + public function a_node_the_wallet_signs_with_right_now_is_learned_instead_of_flagged(): void + { + Notification::fake(); + $this->fakeBtcPay(); + [$user, $store, $connection] = $this->connectedStore(); + app(WalletConfigIntegrityService::class)->baseline($connection, $user); + $this->assertSame([Bolt11Test::SPEC_PAYEE], $connection->fresh()->payee_pubkeys); + + // Provider moved to another node (Blink lnd1 -> lnd2 style): fresh canaries come from it too. + $this->canaryInvoice = Bolt11Test::OTHER_INVOICE; + $this->paidInvoices = [Bolt11Test::OTHER_INVOICE]; + app(SettlementLedgerService::class)->syncInvoice($store, 'paid-1'); + + $fresh = $connection->fresh(); + $this->assertNull($fresh->payee_mismatch_at); + $this->assertSame([Bolt11Test::SPEC_PAYEE, Bolt11Test::OTHER_PAYEE], $fresh->payee_pubkeys); + $this->assertSame(0, UserMessage::where('user_id', $user->id)->where('type', 'security')->count()); + $this->assertDatabaseHas('audit_logs', ['action' => 'wallet_connection.payee_learned']); + $this->assertSame('canary_reconfirm', AuditLog::where('action', 'wallet_connection.payee_learned')->latest('id')->first()->metadata['reason']); + } + + #[Test] + public function reset_command_closes_incidents_and_purges_their_messages(): void + { + Notification::fake(); + $this->fakeBtcPay(); + [$user, $store, $connection] = $this->connectedStore(); + $admin = User::factory()->admin()->create(); + app(WalletConfigIntegrityService::class)->baseline($connection, $user); + $this->paidInvoices = [Bolt11Test::OTHER_INVOICE]; + app(SettlementLedgerService::class)->syncInvoice($store, 'paid-1'); + $this->assertNotNull($connection->fresh()->payee_mismatch_at); + UserMessage::createForUser($admin->id, 'Wallet config drift: other', 'keep me', 'security'); + + $this->artisan('wallet-connections:reset-payee-incidents', ['--dry-run' => true, '--purge-messages' => true]) + ->expectsOutputToContain('Would reset 1 incident(s), 2 security message(s)') + ->assertExitCode(0); + $this->assertNotNull($connection->fresh()->payee_mismatch_at); + + $this->artisan('wallet-connections:reset-payee-incidents', ['--since' => now()->subMinute()->toDateTimeString(), '--purge-messages' => true]) + ->expectsOutputToContain('Reset 1 incident(s), 2 security message(s)') + ->assertExitCode(0); + + $this->assertNull($connection->fresh()->payee_mismatch_at); + $this->assertSame(0, UserMessage::where('user_id', $user->id)->count()); + $this->assertSame(1, UserMessage::where('user_id', $admin->id)->count(), 'unrelated security messages stay'); + $this->assertDatabaseHas('audit_logs', ['action' => 'wallet_connection.payee_incident_reset', 'target_id' => $connection->id]); + } + #[Test] public function reconnecting_the_wallet_relearns_the_payee_and_closes_the_incident(): void { From a1febafcaa86db1607c8979c770964b366834a0a Mon Sep 17 00:00:00 2001 From: webiumsk Date: Mon, 7 Sep 2026 08:50:53 +0200 Subject: [PATCH 2/2] fix(security): purge only the security messages of the incidents being reset user_messages gains wallet_connection_id; every wallet security message (merchant and admin, drift and payee) now carries the connection it is about, and wallet-connections:reset-payee-incidents deletes only the messages of the incidents it resets. Messages written before the column existed have no id and fall back to the --since window so today's false positives can still be cleaned up. Test: a resolved incident of another connection keeps its message, a legacy message inside the window is purged. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01TStPoTJxcruEvTBb7RLcM5 --- app/Console/Commands/ResetPayeeIncidents.php | 12 ++++++++- app/Models/UserMessage.php | 6 ++++- .../WalletSecurity/WalletSecurityNotifier.php | 18 +++++++++---- ..._wallet_connection_id_to_user_messages.php | 25 +++++++++++++++++++ tests/Feature/PayeeAttestationTest.php | 12 +++++++-- 5 files changed, 64 insertions(+), 9 deletions(-) create mode 100644 database/migrations/2026_09_07_100000_add_wallet_connection_id_to_user_messages.php diff --git a/app/Console/Commands/ResetPayeeIncidents.php b/app/Console/Commands/ResetPayeeIncidents.php index 609e69d6..ce0e22c2 100644 --- a/app/Console/Commands/ResetPayeeIncidents.php +++ b/app/Console/Commands/ResetPayeeIncidents.php @@ -48,13 +48,23 @@ public function handle(): int $deleted = 0; if ($this->option('purge-messages')) { + // Only the messages of the incidents reset above. Messages written + // before the wallet_connection_id column existed carry no id and are + // matched by the time window instead. + $connectionIds = $rows->pluck('id')->all(); $messages = UserMessage::query() ->where('type', 'security') ->where(function ($q) { $q->where('title', 'like', 'Payment received by an unknown wallet%') ->orWhere('title', 'like', 'Payee mismatch:%'); }) - ->when($since, fn ($q) => $q->where('created_at', '>=', $since)); + ->where(function ($q) use ($connectionIds, $since) { + $q->whereIn('wallet_connection_id', $connectionIds ?: ['00000000-0000-0000-0000-000000000000']) + ->orWhere(function ($legacy) use ($since) { + $legacy->whereNull('wallet_connection_id') + ->when($since, fn ($qq) => $qq->where('created_at', '>=', $since)); + }); + }); $deleted = $dry ? $messages->count() : $messages->delete(); } diff --git a/app/Models/UserMessage.php b/app/Models/UserMessage.php index c250b758..e38bff78 100644 --- a/app/Models/UserMessage.php +++ b/app/Models/UserMessage.php @@ -18,6 +18,7 @@ class UserMessage extends Model protected $fillable = [ 'user_id', 'type', + 'wallet_connection_id', 'title', 'body', 'link', @@ -55,6 +56,7 @@ public function markAsRead(): void * @param string $type info|success|warning|invoice|subscription|support * @param string|null $link Optional URL * @param string|null $linkText Optional link label + * @param string|null $walletConnectionId Wallet connection a security message is about */ public static function createForUser( int $userId, @@ -62,11 +64,13 @@ public static function createForUser( ?string $body = null, string $type = 'info', ?string $link = null, - ?string $linkText = null + ?string $linkText = null, + ?string $walletConnectionId = null ): self { return self::create([ 'user_id' => $userId, 'type' => $type, + 'wallet_connection_id' => $walletConnectionId, 'title' => $title, 'body' => $body, 'link' => $link, diff --git a/app/Services/WalletSecurity/WalletSecurityNotifier.php b/app/Services/WalletSecurity/WalletSecurityNotifier.php index 54ab669a..9a716c81 100644 --- a/app/Services/WalletSecurity/WalletSecurityNotifier.php +++ b/app/Services/WalletSecurity/WalletSecurityNotifier.php @@ -30,6 +30,7 @@ public function walletReplaced(Store $store, WalletConnection $connection, ?User .($actor ? ' by '.($actor->email ?: 'user #'.$actor->id) : '') .' ('.$this->typeLabel($connection).', '.($connection->masked_secret ?: '******').').' .' If this was not you, reconnect your wallet now and contact support.', + connection: $connection, ); } @@ -43,6 +44,7 @@ public function secretRevealed(Store $store, WalletConnection $connection, ?User 'Wallet secret revealed - '.$store->name, 'The wallet connection secret of "'.$store->name.'" was revealed by '.$who .' ('.$context.'). If this was not you, rotate the credential in your wallet app and reconnect.', + connection: $connection, ); } @@ -56,6 +58,7 @@ public function driftDetected(Store $store, WalletConnection $connection, array 'The payment configuration of "'.$store->name.'" on the payment server no longer matches the wallet you connected. ' .self::describeForMerchant($connection, $diff) .' Payments may be routed elsewhere. Reconnect your wallet immediately and contact support.', + connection: $connection, ); $merchant = $store->user; @@ -72,6 +75,7 @@ public function driftDetected(Store $store, WalletConnection $connection, array 'Store "'.$store->name.'" (owner '.($merchant instanceof User && $merchant->email ? $merchant->email : 'unknown').'): ' .self::describeForMerchant($connection, $diff).' Technical diff: '.$summary, 16711680, + $connection, ); } @@ -86,6 +90,7 @@ public function payeeMismatch(Store $store, WalletConnection $connection, array $store, 'Payment received by an unknown wallet - '.$store->name, $what.' Money paid to "'.$store->name.'" may be going to someone else. Check your wallet balance, reconnect your wallet and contact support immediately.', + connection: $connection, ); $merchant = $store->user; @@ -102,6 +107,7 @@ public function payeeMismatch(Store $store, WalletConnection $connection, array 'Store "'.$store->name.'" (owner '.($merchant instanceof User && $merchant->email ? $merchant->email : 'unknown').'): '.$what .' Full node id: '.$details['pubkey'], 16711680, + $connection, ); } @@ -112,11 +118,12 @@ public function driftResolved(Store $store, WalletConnection $connection): void 'Wallet configuration restored - '.$store->name, 'The payment configuration of "'.$store->name.'" matches your connected wallet again.', 'info', + connection: $connection, ); - $this->adminAlert('Wallet config drift resolved: '.$store->name, 'Configuration matches the baseline again.', 3066993); + $this->adminAlert('Wallet config drift resolved: '.$store->name, 'Configuration matches the baseline again.', 3066993, $connection); } - private function merchantMessage(Store $store, string $title, string $body, string $type = self::TYPE): void + private function merchantMessage(Store $store, string $title, string $body, string $type = self::TYPE, ?WalletConnection $connection = null): void { $merchant = $store->user; if (! $merchant instanceof User) { @@ -130,6 +137,7 @@ private function merchantMessage(Store $store, string $title, string $body, stri $type, rtrim((string) config('app.url'), '/').'/stores/'.$store->id.'/wallet-connection', 'Wallet connection', + $connection?->id, ); } catch (\Throwable $e) { Log::error('Failed to create wallet security message', ['store_id' => $store->id, 'error' => $e->getMessage()]); @@ -137,12 +145,12 @@ private function merchantMessage(Store $store, string $title, string $body, stri } /** In-app security message for every admin plus the support Discord webhook. */ - public function adminAlert(string $title, string $body, int $color): void + public function adminAlert(string $title, string $body, int $color, ?WalletConnection $connection = null): void { $link = rtrim((string) config('app.url'), '/').'/admin/wallet-changes'; try { - User::query()->where('role', 'admin')->each(function (User $admin) use ($title, $body, $link) { - UserMessage::createForUser($admin->id, $title, $body, self::TYPE, $link, 'Wallet change log'); + User::query()->where('role', 'admin')->each(function (User $admin) use ($title, $body, $link, $connection) { + UserMessage::createForUser($admin->id, $title, $body, self::TYPE, $link, 'Wallet change log', $connection?->id); }); } catch (\Throwable $e) { Log::error('Failed to create admin wallet security message', ['error' => $e->getMessage()]); diff --git a/database/migrations/2026_09_07_100000_add_wallet_connection_id_to_user_messages.php b/database/migrations/2026_09_07_100000_add_wallet_connection_id_to_user_messages.php new file mode 100644 index 00000000..92a0dede --- /dev/null +++ b/database/migrations/2026_09_07_100000_add_wallet_connection_id_to_user_messages.php @@ -0,0 +1,25 @@ +uuid('wallet_connection_id')->nullable()->after('type')->index(); + }); + } + + public function down(): void + { + Schema::table('user_messages', function (Blueprint $table) { + $table->dropIndex(['wallet_connection_id']); + $table->dropColumn('wallet_connection_id'); + }); + } +}; diff --git a/tests/Feature/PayeeAttestationTest.php b/tests/Feature/PayeeAttestationTest.php index 20df58b0..503c6ca3 100644 --- a/tests/Feature/PayeeAttestationTest.php +++ b/tests/Feature/PayeeAttestationTest.php @@ -241,20 +241,28 @@ public function reset_command_closes_incidents_and_purges_their_messages(): void $this->paidInvoices = [Bolt11Test::OTHER_INVOICE]; app(SettlementLedgerService::class)->syncInvoice($store, 'paid-1'); $this->assertNotNull($connection->fresh()->payee_mismatch_at); + $this->assertSame($connection->id, UserMessage::where('user_id', $user->id)->value('wallet_connection_id')); UserMessage::createForUser($admin->id, 'Wallet config drift: other', 'keep me', 'security'); + // A genuine incident of another store, already resolved by the admin: its message must survive. + [$otherUser, , $otherConnection] = $this->connectedStore(); + $kept = UserMessage::createForUser($otherUser->id, 'Payment received by an unknown wallet - Other', 'real', 'security', null, null, $otherConnection->id); + // A message from before the column existed (no id) inside the window is purged. + $legacy = UserMessage::createForUser($admin->id, 'Payee mismatch: Legacy', 'old', 'security'); $this->artisan('wallet-connections:reset-payee-incidents', ['--dry-run' => true, '--purge-messages' => true]) - ->expectsOutputToContain('Would reset 1 incident(s), 2 security message(s)') + ->expectsOutputToContain('Would reset 1 incident(s), 3 security message(s)') ->assertExitCode(0); $this->assertNotNull($connection->fresh()->payee_mismatch_at); $this->artisan('wallet-connections:reset-payee-incidents', ['--since' => now()->subMinute()->toDateTimeString(), '--purge-messages' => true]) - ->expectsOutputToContain('Reset 1 incident(s), 2 security message(s)') + ->expectsOutputToContain('Reset 1 incident(s), 3 security message(s)') ->assertExitCode(0); $this->assertNull($connection->fresh()->payee_mismatch_at); $this->assertSame(0, UserMessage::where('user_id', $user->id)->count()); $this->assertSame(1, UserMessage::where('user_id', $admin->id)->count(), 'unrelated security messages stay'); + $this->assertNotNull($kept->fresh(), 'a resolved incident of another connection keeps its message'); + $this->assertNull($legacy->fresh(), 'legacy messages without an id fall back to the time window'); $this->assertDatabaseHas('audit_logs', ['action' => 'wallet_connection.payee_incident_reset', 'target_id' => $connection->id]); }