diff --git a/app/Console/Commands/LearnWalletPayees.php b/app/Console/Commands/LearnWalletPayees.php new file mode 100644 index 00000000..08e134a4 --- /dev/null +++ b/app/Console/Commands/LearnWalletPayees.php @@ -0,0 +1,43 @@ +where('status', 'connected'); + if ($store = $this->option('store')) { + $query->where('store_id', $store); + } elseif (! $this->option('all')) { + $query->whereNull('payee_pubkeys'); + } + + $learned = 0; + $skipped = 0; + $query->orderBy('id')->chunkById(25, function ($connections) use ($payees, &$learned, &$skipped) { + foreach ($connections as $connection) { + if ($payees->learn($connection, null, 'command')) { + $learned++; + $this->line("learned store {$connection->store_id}: ".implode(',', $connection->fresh()->payee_pubkeys ?? [])); + } else { + $skipped++; + } + } + }); + + $this->info("Payee allow-lists - learned: {$learned}, skipped: {$skipped}"); + + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/Admin/WalletChangeLogController.php b/app/Http/Controllers/Admin/WalletChangeLogController.php index 3b91f23f..23531ed0 100644 --- a/app/Http/Controllers/Admin/WalletChangeLogController.php +++ b/app/Http/Controllers/Admin/WalletChangeLogController.php @@ -7,6 +7,7 @@ use App\Models\Store; use App\Models\User; use App\Models\WalletConnection; +use App\Services\WalletSecurity\PayeeAttestationService; use App\Services\WalletSecurity\WalletConfigIntegrityService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -30,6 +31,9 @@ class WalletChangeLogController extends Controller 'wallet_connection.config_baselined', 'wallet_connection.drift_detected', 'wallet_connection.drift_resolved', + 'wallet_connection.payee_learned', + 'wallet_connection.payee_mismatch', + 'wallet_connection.payee_accepted', 'store.cashu_fallback_configured', ]; @@ -135,9 +139,11 @@ public function index(Request $request): JsonResponse public function drifts(): JsonResponse { $rows = WalletConnection::query() - ->whereNotNull('drift_detected_at') + ->where(function ($q) { + $q->whereNotNull('drift_detected_at')->orWhereNotNull('payee_mismatch_at'); + }) ->with(['store:id,name,user_id', 'store.user:id,email']) - ->orderByDesc('drift_detected_at') + ->orderByRaw('COALESCE(drift_detected_at, payee_mismatch_at) DESC') ->get(); $data = []; @@ -153,6 +159,10 @@ public function drifts(): JsonResponse 'drift_detected_at' => $c->drift_detected_at?->toIso8601String(), 'config_verified_at' => $c->config_verified_at?->toIso8601String(), 'drift_details' => $c->drift_details, + 'payee_pubkeys' => $c->payee_pubkeys, + 'payee_learn_source' => $c->payee_learn_source, + 'payee_mismatch_at' => $c->payee_mismatch_at?->toIso8601String(), + 'payee_mismatch_details' => $c->payee_mismatch_details, ]; } @@ -176,6 +186,23 @@ public function rebaseline(Request $request, WalletConnection $connection, Walle return response()->json(['data' => ['baselined' => $ok]], $ok ? 200 : 502); } + /** Admin confirms the node that signed the mismatching invoice (e.g. the merchant changed provider). */ + public function acceptPayee(Request $request, WalletConnection $connection, PayeeAttestationService $payees): JsonResponse + { + $validated = $request->validate(['pubkey' => ['required', 'string', 'regex:/^0[23][0-9a-fA-F]{64}$/']]); + $payees->accept($connection, $validated['pubkey'], $request->user()); + + return response()->json(['data' => ['payee_pubkeys' => $connection->fresh()?->payee_pubkeys]]); + } + + /** Relearn the payee allow-list from a fresh canary invoice. */ + public function learnPayee(Request $request, WalletConnection $connection, PayeeAttestationService $payees): JsonResponse + { + $ok = $payees->learn($connection, $request->user(), 'admin_relearn'); + + return response()->json(['data' => ['learned' => $ok, 'payee_pubkeys' => $connection->fresh()?->payee_pubkeys]], $ok ? 200 : 502); + } + /** Store the row is about: metadata.store_id, or the target itself for store-targeted rows. */ private function storeIdOf(AuditLog $log): ?string { diff --git a/app/Http/Controllers/WalletConnectionController.php b/app/Http/Controllers/WalletConnectionController.php index c870b68d..e615d38a 100644 --- a/app/Http/Controllers/WalletConnectionController.php +++ b/app/Http/Controllers/WalletConnectionController.php @@ -88,6 +88,10 @@ public function show(Request $request) 'config_verified_at' => $connection->config_verified_at?->toIso8601String(), 'drift_detected_at' => $connection->drift_detected_at?->toIso8601String(), 'drift_details' => $connection->drift_details, + // Payee attestation (PayeeAttestationService): node ids allowed to sign this store's invoices. + 'payee_learned_at' => $connection->payee_learned_at?->toIso8601String(), + 'payee_mismatch_at' => $connection->payee_mismatch_at?->toIso8601String(), + 'payee_mismatch_details' => $connection->payee_mismatch_details, ], ]); } diff --git a/app/Models/WalletConnection.php b/app/Models/WalletConnection.php index d7065331..ab3f91ff 100644 --- a/app/Models/WalletConnection.php +++ b/app/Models/WalletConnection.php @@ -14,6 +14,11 @@ * @property array|null $config_snapshot * @property Carbon|null $config_verified_at * @property Carbon|null $drift_detected_at + * @property list|null $payee_pubkeys + * @property string|null $payee_learn_source + * @property Carbon|null $payee_learned_at + * @property Carbon|null $payee_mismatch_at + * @property array{pubkey: string, invoice_id: string|null, method: string|null, expected: list, seen_at: string}|null $payee_mismatch_details * @property array{changed: string[], added: string[], removed: string[], details?: array}|null $drift_details */ class WalletConnection extends Model @@ -43,6 +48,11 @@ class WalletConnection extends Model 'config_verified_at', 'drift_detected_at', 'drift_details', + 'payee_pubkeys', + 'payee_learn_source', + 'payee_learned_at', + 'payee_mismatch_at', + 'payee_mismatch_details', ]; /** @@ -62,6 +72,10 @@ protected function casts(): array 'config_verified_at' => 'datetime', 'drift_detected_at' => 'datetime', 'drift_details' => 'array', + 'payee_pubkeys' => 'array', + 'payee_learned_at' => 'datetime', + 'payee_mismatch_at' => 'datetime', + 'payee_mismatch_details' => 'array', ]; } diff --git a/app/Notifications/WalletPayeeMismatchNotification.php b/app/Notifications/WalletPayeeMismatchNotification.php new file mode 100644 index 00000000..2a8e41a0 --- /dev/null +++ b/app/Notifications/WalletPayeeMismatchNotification.php @@ -0,0 +1,57 @@ +, seen_at: string} $details */ + public function __construct( + public Store $store, + public WalletConnection $walletConnection, + public array $details, + ) {} + + public function via(object $notifiable): array + { + return ['mail']; + } + + public function toMail(object $notifiable): MailMessage + { + $appUrl = rtrim(config('app.url', 'http://localhost:8080'), '/'); + $storeUrl = "{$appUrl}/stores/{$this->store->id}/wallet-connection"; + + return (new MailMessage) + ->error() + ->subject('SECURITY: payment received by an unknown wallet - '.$this->store->name) + ->line('A Lightning payment to your store **'.$this->store->name.'** was received by a node that is not the one your connected wallet uses.') + ->line('**Invoice:** '.($this->details['invoice_id'] ?? 'unknown')) + ->line('**Receiving node:** `'.$this->details['pubkey'].'`') + ->line('**Node(s) of your wallet:** `'.implode('`, `', $this->details['expected']).'`') + ->line('Money paid to this store may be going to someone else. Check your wallet balance against the payments listed in Satflux.') + ->line('**What to do now:** reconnect your wallet (confirm with the email code) and contact support immediately so we can investigate.') + ->action('Wallet connection', $storeUrl) + ->line('If you recently changed your wallet provider yourself, support can confirm the new node for you.'); + } + + public function toArray(object $notifiable): array + { + return [ + 'store_id' => $this->store->id, + 'wallet_connection_id' => $this->walletConnection->id, + 'details' => $this->details, + ]; + } +} diff --git a/app/Services/Boltz/SettlementLedgerService.php b/app/Services/Boltz/SettlementLedgerService.php index 3cb2a66c..9ac54aab 100644 --- a/app/Services/Boltz/SettlementLedgerService.php +++ b/app/Services/Boltz/SettlementLedgerService.php @@ -6,6 +6,7 @@ use App\Models\StoreSettlement; use App\Models\User; use App\Services\BtcPay\InvoiceService; +use App\Services\WalletSecurity\PayeeAttestationService; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Log; @@ -51,6 +52,14 @@ 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)) { diff --git a/app/Services/BtcPay/InvoiceService.php b/app/Services/BtcPay/InvoiceService.php index b43ed9d6..0b3d95ac 100644 --- a/app/Services/BtcPay/InvoiceService.php +++ b/app/Services/BtcPay/InvoiceService.php @@ -46,6 +46,15 @@ public function createInvoice(string $storeId, array $payload, ?string $userApiK } } + /** Archive (soft-delete) a BTCPay invoice - used for the payee-attestation canary. */ + public function archiveInvoice(string $storeId, string $invoiceId, ?string $userApiKey = null): void + { + $this->client->withUserKey( + $userApiKey, + fn () => $this->client->delete("/api/v1/stores/{$storeId}/invoices/{$invoiceId}") + ); + } + public function listInvoices(string $storeId, array $filters = [], ?int $skip = null, ?int $take = null, ?string $userApiKey = null): array { $query = $filters; @@ -86,6 +95,9 @@ public function forgetInvoiceCache(string $storeId, string $invoiceId, ?string $ { $apiKeyHash = $userApiKey ? hash('sha256', $userApiKey) : 'server'; Cache::forget("btcpay:invoice:{$storeId}:{$invoiceId}:{$apiKeyHash}"); + // The payment list changes with every settlement: drop it together + // with the invoice, otherwise a webhook-driven resync reads stale payments. + Cache::forget("btcpay:invoice:payment_methods:{$storeId}:{$invoiceId}:{$apiKeyHash}"); } public function getInvoice(string $storeId, string $invoiceId, ?string $userApiKey = null): array diff --git a/app/Services/WalletSecurity/Bolt11.php b/app/Services/WalletSecurity/Bolt11.php new file mode 100644 index 00000000..3361df02 --- /dev/null +++ b/app/Services/WalletSecurity/Bolt11.php @@ -0,0 +1,291 @@ + null, 'description' => null, 'expiry' => 3600, 'payee_field' => null]; + $pos = 7; + while ($pos + 3 <= count($data)) { + $type = $data[$pos]; + $len = ($data[$pos + 1] << 5) | $data[$pos + 2]; + $body = array_slice($data, $pos + 3, $len); + if (count($body) !== $len) { + throw new \InvalidArgumentException('Truncated tagged field'); + } + $pos += 3 + $len; + switch ($type) { + case 1: // p - payment hash + if ($len === 52) { + $fields['payment_hash'] = bin2hex(self::wordsToBytes($body, false)); + } + break; + case 13: // d - description + $fields['description'] = self::wordsToBytes($body, false); + break; + case 6: // x - expiry + $fields['expiry'] = self::wordsToInt($body); + break; + case 19: // n - payee node id + if ($len === 53) { + $fields['payee_field'] = bin2hex(self::wordsToBytes($body, false)); + } + break; + } + } + + [$network, $amountMsat] = self::parseHrp($hrp); + + $preimage = $hrp.self::wordsToBytes($data, true); + $hash = hash('sha256', $preimage, true); + // The signer is always recovered from the signature; an explicit `n` + // field is only accepted when it is that signer (a forged or tampered + // `n` must never become the payee we attest against). + $recovered = self::recoverPubKey($hash, substr($sig, 0, 32), substr($sig, 32, 32), ord($sig[64])); + $payee = $recovered; + $source = 'recovered'; + if ($fields['payee_field'] !== null) { + if (! hash_equals($recovered, $fields['payee_field'])) { + throw new \InvalidArgumentException('Payee field does not match the invoice signature'); + } + $source = 'field'; + } + + return [ + 'hrp' => $hrp, + 'network' => $network, + 'amount_msat' => $amountMsat, + 'timestamp' => $timestamp, + 'payment_hash' => $fields['payment_hash'], + 'description' => $fields['description'], + 'expiry' => $fields['expiry'], + 'payee' => $payee, + 'payee_source' => $source, + ]; + } + + /** Payee node id (33-byte compressed, hex) or null when the string is not a valid invoice. */ + public static function payee(string $invoice): ?string + { + try { + return self::decode($invoice)['payee']; + } catch (\Throwable) { + return null; + } + } + + /** @return array{0: string, 1: int|null} network, amount in msat */ + private static function parseHrp(string $hrp): array + { + if (preg_match('/^ln([a-z]+?)(\d+)?([munp])?$/', $hrp, $m) !== 1) { + throw new \InvalidArgumentException('Malformed human-readable part'); + } + $network = $m[1]; + $amount = $m[2] ?? ''; + if ($amount === '') { + return [$network, null]; + } + // amount is in BTC units scaled by the multiplier; convert to msat exactly. + $msatPerBtc = '100000000000'; + $divisor = match ($m[3] ?? '') { + '' => '1', + 'm' => '1000', + 'u' => '1000000', + 'n' => '1000000000', + 'p' => '1000000000000', + }; + $msat = bcdiv(bcmul($amount, $msatPerBtc, 0), $divisor, 0); + + return [$network, (int) $msat]; + } + + /** @return array{0: string, 1: list} */ + private static function bech32Decode(string $str): array + { + if ($str !== strtolower($str) && $str !== strtoupper($str)) { + throw new \InvalidArgumentException('Mixed-case bech32'); + } + $str = strtolower($str); + $split = strrpos($str, '1'); + if ($split === false || $split < 1 || $split + 7 > strlen($str)) { + throw new \InvalidArgumentException('Missing separator'); + } + $hrp = substr($str, 0, $split); + $words = []; + for ($i = $split + 1, $n = strlen($str); $i < $n; $i++) { + $v = strpos(self::CHARSET, $str[$i]); + if ($v === false) { + throw new \InvalidArgumentException('Invalid character'); + } + $words[] = $v; + } + if (self::polymod(array_merge(self::hrpExpand($hrp), $words)) !== 1) { + throw new \InvalidArgumentException('Bad checksum'); + } + + return [$hrp, array_slice($words, 0, -6)]; + } + + /** @return list */ + private static function hrpExpand(string $hrp): array + { + $out = []; + $len = strlen($hrp); + for ($i = 0; $i < $len; $i++) { + $out[] = ord($hrp[$i]) >> 5; + } + $out[] = 0; + for ($i = 0; $i < $len; $i++) { + $out[] = ord($hrp[$i]) & 31; + } + + return $out; + } + + /** @param list $values */ + private static function polymod(array $values): int + { + $chk = 1; + foreach ($values as $v) { + $top = $chk >> 25; + $chk = (($chk & 0x1FFFFFF) << 5) ^ $v; + for ($i = 0; $i < 5; $i++) { + if (($top >> $i) & 1) { + $chk ^= self::GENERATOR[$i]; + } + } + } + + return $chk; + } + + /** + * 5-bit words to bytes. With $pad the trailing partial byte is zero-padded + * (signature preimage rule); without it, leftover bits are dropped. + * + * @param list $words + */ + private static function wordsToBytes(array $words, bool $pad): string + { + $acc = 0; + $bits = 0; + $out = ''; + foreach ($words as $w) { + $acc = (($acc << 5) | $w) & 0xFFFFFFFF; + $bits += 5; + while ($bits >= 8) { + $bits -= 8; + $out .= chr(($acc >> $bits) & 0xFF); + } + } + if ($pad && $bits > 0) { + $out .= chr(($acc << (8 - $bits)) & 0xFF); + } + + return $out; + } + + /** @param list $words */ + private static function wordsToInt(array $words): int + { + $n = 0; + foreach ($words as $w) { + $n = ($n << 5) | $w; + } + + return $n; + } + + /** ECDSA public key recovery on secp256k1 (compressed hex). */ + private static function recoverPubKey(string $hash, string $r, string $s, int $recId): string + { + if ($recId < 0 || $recId > 3) { + throw new \InvalidArgumentException('Invalid recovery id'); + } + $generator = EccFactory::getSecgCurves()->generator256k1(); + $curve = $generator->getCurve(); + $n = $generator->getOrder(); + $p = $curve->getPrime(); + + $rN = gmp_init(bin2hex($r), 16); + $sN = gmp_init(bin2hex($s), 16); + $e = gmp_init(bin2hex($hash), 16); + if (gmp_cmp($rN, 1) < 0 || gmp_cmp($rN, $n) >= 0 || gmp_cmp($sN, 1) < 0 || gmp_cmp($sN, $n) >= 0) { + throw new \InvalidArgumentException('Signature out of range'); + } + + $x = gmp_add($rN, gmp_mul(gmp_init($recId >> 1), $n)); + if (gmp_cmp($x, $p) >= 0) { + throw new \InvalidArgumentException('Recovery x out of range'); + } + $y = $curve->recoverYfromX(($recId & 1) === 1, $x); + $R = $curve->getPoint($x, $y, $n); + + // Q = r^-1 (s*R - e*G) + $rInv = gmp_invert($rN, $n); + $sR = $R->mul($sN); + $eG = $generator->mul(gmp_mod($e, $n)); + $negEG = $curve->getPoint($eG->getX(), gmp_sub($p, $eG->getY()), $n); + $Q = $sR->add($negEG)->mul($rInv); + if ($Q->isInfinity()) { + throw new \InvalidArgumentException('Recovered point at infinity'); + } + + $prefix = gmp_cmp(gmp_mod($Q->getY(), 2), 0) === 0 ? '02' : '03'; + + return $prefix.str_pad(gmp_strval($Q->getX(), 16), 64, '0', STR_PAD_LEFT); + } +} diff --git a/app/Services/WalletSecurity/PayeeAttestationService.php b/app/Services/WalletSecurity/PayeeAttestationService.php new file mode 100644 index 00000000..ba267a4b --- /dev/null +++ b/app/Services/WalletSecurity/PayeeAttestationService.php @@ -0,0 +1,280 @@ +store; + $owner = $store instanceof Store ? $store->user : null; + if (! $store instanceof Store || ! $owner instanceof User || ! filled($owner->btcpay_api_key)) { + return false; + } + $apiKey = (string) $owner->btcpay_api_key; + $btcpayStoreId = (string) $store->btcpay_store_id; + + $invoiceId = null; + try { + $invoice = $this->invoices->createInvoice($btcpayStoreId, [ + 'amount' => self::CANARY_AMOUNT_BTC, + 'currency' => 'BTC', + 'metadata' => ['satflux_canary' => true, 'itemDesc' => 'Satflux wallet check (not payable)'], + 'checkout' => ['expirationMinutes' => 1, 'paymentMethods' => self::LIGHTNING_METHODS], + ], $apiKey); + $invoiceId = isset($invoice['id']) ? (string) $invoice['id'] : null; + if ($invoiceId === null || $invoiceId === '') { + throw new \RuntimeException('Canary invoice has no id'); + } + $this->invoices->forgetInvoiceCache($btcpayStoreId, $invoiceId, $apiKey); + $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; + } + + $payee = null; + foreach (self::lightningDestinations($methods, requirePayments: false) as $bolt11) { + $payee = Bolt11::payee($bolt11); + if ($payee !== null) { + break; + } + } + $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; + } + + $this->setAllowlist($connection, [$payee], 'canary', $by, $reason); + + return true; + } + + /** + * Check every settled Lightning payment of an invoice (called from the + * settlement ledger sync with the payment methods it already fetched). + * + * @param list $methods Greenfield invoice payment-methods payload + * @return array bolt11 => outcome + */ + public function attestInvoice(Store $store, string $invoiceId, array $methods): array + { + $connection = $store->walletConnection; + if (! $connection instanceof WalletConnection || $connection->status !== 'connected') { + return []; + } + + $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), + ]); + } + + return $results; + } + + /** + * @param array{invoice_id?: string|null, method?: string|null} $context + * @return 'ok'|'learned'|'mismatch'|'unparsed'|'skipped' + */ + public function attestBolt11(WalletConnection $connection, string $bolt11, array $context = []): string + { + if ($connection->status !== 'connected') { + return 'skipped'; + } + $payee = Bolt11::payee($bolt11); + if ($payee === null) { + Log::warning('Payee attestation: invoice could not be decoded', [ + 'connection_id' => $connection->id, + 'invoice_id' => $context['invoice_id'] ?? null, + ]); + + return 'unparsed'; + } + + $allowed = $connection->payee_pubkeys ?? []; + if ($allowed === []) { + // Trust on first use - the canary could not be read at baseline time. + $this->setAllowlist($connection, [$payee], 'first_payment', null, 'first_payment', $context); + + return 'learned'; + } + if (in_array($payee, $allowed, true)) { + return 'ok'; + } + + $store = $connection->store; + $details = [ + 'pubkey' => $payee, + 'invoice_id' => $context['invoice_id'] ?? null, + 'method' => $context['method'] ?? null, + 'expected' => $allowed, + 'seen_at' => now()->toIso8601String(), + ]; + $connection->payee_mismatch_details = $details; + $connection->save(); + + // Only the first observer of a mismatch raises the incident. + $first = WalletConnection::query() + ->whereKey($connection->id) + ->whereNull('payee_mismatch_at') + ->update(['payee_mismatch_at' => now()]) === 1; + $connection->refresh(); + + if ($first && $store instanceof Store) { + AuditLog::log('wallet_connection.payee_mismatch', 'wallet_connection', $connection->id, [ + 'store_id' => $store->id, + ...$details, + ], null); + Log::error('Payee attestation mismatch', ['connection_id' => $connection->id, ...$details]); + $this->notifier->payeeMismatch($store, $connection, $details); + } + + return 'mismatch'; + } + + /** 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 + { + $pubkey = strtolower(trim($pubkey)); + if (preg_match('/^0[23][0-9a-f]{64}$/', $pubkey) !== 1) { + throw new \InvalidArgumentException('Not a compressed secp256k1 public key'); + } + /** @var list $list */ + $list = array_unique([...($connection->payee_pubkeys ?? []), $pubkey]); + $connection->forceFill([ + 'payee_pubkeys' => $list, + 'payee_learned_at' => $connection->payee_learned_at ?? now(), + 'payee_learn_source' => $connection->payee_learn_source ?? 'admin', + 'payee_mismatch_at' => null, + 'payee_mismatch_details' => null, + ])->save(); + + AuditLog::log('wallet_connection.payee_accepted', 'wallet_connection', $connection->id, [ + 'store_id' => $connection->store_id, + 'pubkey' => $pubkey, + 'allowed' => $list, + ], $admin->id); + } + + /** + * @param list $pubkeys + * @param array $context + */ + protected function setAllowlist(WalletConnection $connection, array $pubkeys, string $source, ?User $by, string $reason, array $context = []): void + { + $connection->forceFill([ + 'payee_pubkeys' => $pubkeys, + 'payee_learn_source' => $source, + 'payee_learned_at' => now(), + 'payee_mismatch_at' => null, + 'payee_mismatch_details' => null, + ])->save(); + + AuditLog::log('wallet_connection.payee_learned', 'wallet_connection', $connection->id, [ + 'store_id' => $connection->store_id, + 'source' => $source, + 'reason' => $reason, + 'pubkeys' => $pubkeys, + ...array_filter($context, fn ($v) => $v !== null), + ], $by?->id); + } + + /** + * BOLT11 strings of the Lightning payment methods of an invoice payload: + * the method-level destination plus every payment's own destination. + * + * @param list $methods + * @return array "METHOD" or "METHOD#i" => bolt11 + */ + public static function lightningDestinations(array $methods, bool $requirePayments): array + { + $out = []; + 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'] : []; + if ($requirePayments && $payments === []) { + continue; + } + $destination = $method['destination'] ?? null; + if (is_string($destination) && str_starts_with(strtolower($destination), 'ln')) { + $out[$id] = $destination; + } + foreach ($payments as $i => $payment) { + $d = is_array($payment) ? ($payment['destination'] ?? null) : null; + if (is_string($d) && str_starts_with(strtolower($d), 'ln') && ! in_array($d, $out, true)) { + $out[$id.'#'.$i] = $d; + } + } + } + + return $out; + } + + private function archiveQuietly(string $btcpayStoreId, ?string $invoiceId, string $apiKey): void + { + if ($invoiceId === null || $invoiceId === '') { + return; + } + try { + $this->invoices->archiveInvoice($btcpayStoreId, $invoiceId, $apiKey); + } catch (\Throwable $e) { + Log::info('Payee canary invoice could not be archived', ['invoice_id' => $invoiceId, 'error' => $e->getMessage()]); + } + } +} diff --git a/app/Services/WalletSecurity/WalletConfigIntegrityService.php b/app/Services/WalletSecurity/WalletConfigIntegrityService.php index 9279f79c..d2dffdaa 100644 --- a/app/Services/WalletSecurity/WalletConfigIntegrityService.php +++ b/app/Services/WalletSecurity/WalletConfigIntegrityService.php @@ -29,6 +29,7 @@ class WalletConfigIntegrityService public function __construct( protected StoreService $stores, protected WalletSecurityNotifier $notifier, + protected PayeeAttestationService $payees, ) {} /** @@ -122,6 +123,10 @@ public function baseline(WalletConnection $connection, ?User $by = null, string 'cleared_drift' => $hadDrift, ], $by?->id); + // A new wallet also means a new payee node: relearn it from a canary + // invoice (best-effort; first settled payment is the fallback). + $this->payees->learn($connection, $by, $reason); + return true; } diff --git a/app/Services/WalletSecurity/WalletSecurityNotifier.php b/app/Services/WalletSecurity/WalletSecurityNotifier.php index 79082a94..54ab669a 100644 --- a/app/Services/WalletSecurity/WalletSecurityNotifier.php +++ b/app/Services/WalletSecurity/WalletSecurityNotifier.php @@ -7,6 +7,7 @@ use App\Models\UserMessage; use App\Models\WalletConnection; use App\Notifications\WalletConfigDriftNotification; +use App\Notifications\WalletPayeeMismatchNotification; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; @@ -74,6 +75,36 @@ public function driftDetected(Store $store, WalletConnection $connection, array ); } + /** @param array{pubkey: string, invoice_id: string|null, method: string|null, expected: list, seen_at: string} $details */ + public function payeeMismatch(Store $store, WalletConnection $connection, array $details): void + { + $short = fn (string $k) => substr($k, 0, 10).'…'.substr($k, -6); + $what = 'A Lightning payment'.($details['invoice_id'] ? ' on invoice '.$details['invoice_id'] : '') + .' was received by node '.$short($details['pubkey']) + .', not by the node your wallet uses ('.implode(', ', array_map($short, $details['expected'])).').'; + $this->merchantMessage( + $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.', + ); + + $merchant = $store->user; + if ($merchant instanceof User && $merchant->email) { + try { + $merchant->notify(new WalletPayeeMismatchNotification($store, $connection, $details)); + } catch (\Throwable $e) { + Log::error('Failed to send payee mismatch e-mail', ['store_id' => $store->id, 'error' => $e->getMessage()]); + } + } + + $this->adminAlert( + 'Payee mismatch: '.$store->name, + 'Store "'.$store->name.'" (owner '.($merchant instanceof User && $merchant->email ? $merchant->email : 'unknown').'): '.$what + .' Full node id: '.$details['pubkey'], + 16711680, + ); + } + public function driftResolved(Store $store, WalletConnection $connection): void { $this->merchantMessage( diff --git a/database/migrations/2026_09_06_120000_add_payee_attestation_to_wallet_connections.php b/database/migrations/2026_09_06_120000_add_payee_attestation_to_wallet_connections.php new file mode 100644 index 00000000..663d0e28 --- /dev/null +++ b/database/migrations/2026_09_06_120000_add_payee_attestation_to_wallet_connections.php @@ -0,0 +1,29 @@ +json('payee_pubkeys')->nullable()->after('drift_details'); + $table->string('payee_learn_source', 32)->nullable()->after('payee_pubkeys'); + $table->timestamp('payee_learned_at')->nullable()->after('payee_learn_source'); + $table->timestamp('payee_mismatch_at')->nullable()->after('payee_learned_at'); + $table->json('payee_mismatch_details')->nullable()->after('payee_mismatch_at'); + }); + } + + public function down(): void + { + Schema::table('wallet_connections', function (Blueprint $table) { + $table->dropColumn(['payee_pubkeys', 'payee_learn_source', 'payee_learned_at', 'payee_mismatch_at', 'payee_mismatch_details']); + }); + } +}; diff --git a/docs/user/en/wallet-security-alerts.md b/docs/user/en/wallet-security-alerts.md index 3ebff86e..0ea79e24 100644 --- a/docs/user/en/wallet-security-alerts.md +++ b/docs/user/en/wallet-security-alerts.md @@ -35,6 +35,14 @@ When a difference is detected: **What to do:** open the Wallet connection page, confirm the change with the email code and reconnect your wallet. Reconnecting records a new fingerprint and closes the incident. Then contact support so we can investigate how the configuration was changed. +## Who receives the money - payee verification + +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. + +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. + ## What Satflux cannot see -Detection compares what the payment server reports with what you connected. It covers changes made through the payment server's own interface or API, including a stolen API key. It cannot detect a fully compromised payment server that lies about its configuration. Keep an eye on your wallet balance against the payments shown in Satflux; if they do not match, contact support. +Detection compares what the payment server reports with what you connected. It covers changes made through the payment server's own interface or API, including a stolen API key. A fully compromised payment server could lie about its configuration; that is why the payee verification above looks at the paid invoices themselves. A server that also forges invoice data would show payments that never reached you, so keep an eye on your wallet balance against the payments shown in Satflux and contact support if they do not match. diff --git a/docs/user/sk/wallet-security-alerts.md b/docs/user/sk/wallet-security-alerts.md index 6982e69f..7f216e74 100644 --- a/docs/user/sk/wallet-security-alerts.md +++ b/docs/user/sk/wallet-security-alerts.md @@ -35,6 +35,14 @@ Keď sa zistí rozdiel: **Čo urobiť:** otvorte stránku Pripojenie peňaženky, potvrďte zmenu e-mailovým kódom a znova pripojte svoju peňaženku. Nové pripojenie uloží nový odtlačok a incident uzavrie. Potom kontaktujte podporu, aby sme prešetrili, ako bola konfigurácia zmenená. +## Kto dostáva peniaze - overenie príjemcu + +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é. + +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. + ## Čo Satflux nevidí -Detekcia porovnáva, čo platobný server hlási, s tým, čo ste pripojili. Pokrýva zmeny urobené cez rozhranie alebo API platobného servera vrátane ukradnutého API kľúča. Nedokáže odhaliť úplne kompromitovaný platobný server, ktorý o svojej konfigurácii klame. Sledujte zostatok svojej peňaženky oproti platbám zobrazeným v Satfluxe; ak nesedia, kontaktujte podporu. +Detekcia porovnáva, čo platobný server hlási, s tým, čo ste pripojili. Pokrýva zmeny urobené cez rozhranie alebo API platobného servera vrátane ukradnutého API kľúča. Úplne kompromitovaný platobný server by mohol o svojej konfigurácii klamať; preto sa overenie príjemcu vyššie pozerá priamo na zaplatené faktúry. Server, ktorý by sfalšoval aj údaje o faktúrach, by ukazoval platby, ktoré k vám nikdy nedošli, preto sledujte zostatok svojej peňaženky oproti platbám zobrazeným v Satfluxe a ak nesedia, kontaktujte podporu. diff --git a/resources/js/components/stores/wallet-connection/ConnectionReadonlyCard.vue b/resources/js/components/stores/wallet-connection/ConnectionReadonlyCard.vue index 96ca7470..cc8fa77c 100644 --- a/resources/js/components/stores/wallet-connection/ConnectionReadonlyCard.vue +++ b/resources/js/components/stores/wallet-connection/ConnectionReadonlyCard.vue @@ -74,6 +74,28 @@

{{ t("stores.wallet_drift_action") }}

+
· {{ d.owner_email }}

-

+

{{ t("admin.wallet_changes.drift_since") }} {{ formatDate(d.drift_detected_at) }} · {{ t("admin.wallet_changes.last_check") }} {{ formatDate(d.config_verified_at) }}

@@ -52,6 +52,21 @@

{{ diffSummary(d.drift_details) }}

+ +

+ {{ t("admin.wallet_changes.allowed_nodes") }} ({{ d.payee_learn_source }}): {{ d.payee_pubkeys.join(", ") }} +

+ +
@@ -212,6 +246,10 @@ interface DriftRow { drift_detected_at: string | null; config_verified_at: string | null; drift_details: DriftDiff | null; + payee_pubkeys: string[] | null; + payee_learn_source: string | null; + payee_mismatch_at: string | null; + payee_mismatch_details: { pubkey: string; invoice_id: string | null; expected: string[] } | null; } const { t } = useI18n(); @@ -287,6 +325,32 @@ async function rebaseline(id: string) { } } +async function acceptPayee(id: string, pubkey: string) { + busyId.value = id; + try { + await api.post(`/admin/wallet-connections/${id}/accept-payee`, { pubkey }); + flash.success(t("admin.wallet_changes.payee_accepted")); + await reload(); + } catch { + // The API interceptor already surfaced the failure as a flash. + } finally { + busyId.value = null; + } +} + +async function relearnPayee(id: string) { + busyId.value = id; + try { + await api.post(`/admin/wallet-connections/${id}/learn-payee`); + flash.success(t("admin.wallet_changes.payee_relearned")); + await reload(); + } catch { + flash.error(t("admin.wallet_changes.payee_relearn_failed")); + } finally { + busyId.value = null; + } +} + function diffSummary(diff: DriftDiff | null): string { if (!diff) return ""; const parts: string[] = []; @@ -315,17 +379,21 @@ function detailText(row: LogRow): string { if (Array.isArray(m.methods)) bits.push((m.methods as string[]).join(", ")); if (typeof m.challenge_id === "string") bits.push(`challenge ${m.challenge_id.slice(-8)}`); if (typeof m.success === "boolean") bits.push(m.success ? "success" : "failed"); + if (typeof m.pubkey === "string") bits.push(`node ${m.pubkey}`); + if (Array.isArray(m.pubkeys)) bits.push(`nodes ${(m.pubkeys as string[]).join(", ")}`); + if (typeof m.source === "string") bits.push(m.source); + if (typeof m.invoice_id === "string") bits.push(`invoice ${m.invoice_id}`); return bits.join(" · "); } function rowClass(action: string): string { - if (action === "wallet_connection.drift_detected") return "bg-red-500/10"; + if (action === "wallet_connection.drift_detected" || action === "wallet_connection.payee_mismatch") return "bg-red-500/10"; if (action === "wallet_connection.drift_resolved") return "bg-emerald-500/5"; return ""; } function badgeClass(action: string): string { - if (action === "wallet_connection.drift_detected") return "bg-red-500/20 text-red-300"; + if (action === "wallet_connection.drift_detected" || action === "wallet_connection.payee_mismatch") return "bg-red-500/20 text-red-300"; if (action === "wallet_connection.drift_resolved" || action === "wallet_connection.config_baselined") return "bg-emerald-500/20 text-emerald-300"; if (action === "wallet_connection.revealed") return "bg-amber-500/20 text-amber-300"; if (action.startsWith("wallet_connection.change_")) return "bg-indigo-500/20 text-indigo-300"; diff --git a/resources/js/services/api.ts b/resources/js/services/api.ts index 4fb24ea4..d5b8dab3 100644 --- a/resources/js/services/api.ts +++ b/resources/js/services/api.ts @@ -249,6 +249,19 @@ export interface WalletConnectionDetails { removed: string[]; details?: Record; } | null; + /** Payee attestation: when the Lightning node behind the wallet was learned. */ + payee_learned_at?: string | null; + /** Set while a settled payment was signed by a node that is not the wallet's. */ + payee_mismatch_at?: string | null; + payee_mismatch_details?: WalletPayeeMismatch | null; +} + +export interface WalletPayeeMismatch { + pubkey: string; + invoice_id: string | null; + method: string | null; + expected: string[]; + seen_at: string; } export interface WalletChangeConfirmationState { diff --git a/routes/api.php b/routes/api.php index 687862b5..176817d8 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1074,6 +1074,8 @@ Route::post('/admin/wallet-connections/{connection}/verify-config', [WalletChangeLogController::class, 'verify']); Route::post('/admin/wallet-connections/{connection}/rebaseline', [WalletChangeLogController::class, 'rebaseline']) ->middleware(AuditLog::class.':wallet_connection.config_rebaselined_by_admin'); + Route::post('/admin/wallet-connections/{connection}/accept-payee', [WalletChangeLogController::class, 'acceptPayee']); + Route::post('/admin/wallet-connections/{connection}/learn-payee', [WalletChangeLogController::class, 'learnPayee']); Route::get('/admin/system-health', [SystemHealthController::class, 'show']); Route::get('/admin/system-health/history', [SystemHealthController::class, 'history']); Route::get('/admin/stats', [AdminController::class, 'stats']); diff --git a/routes/console.php b/routes/console.php index 3ad76b20..90e8b7dc 100644 --- a/routes/console.php +++ b/routes/console.php @@ -113,6 +113,13 @@ ->withoutOverlapping() ->runInBackground(); +// Payee attestation: wallets still without an allow-list (canary failed at +// connect time) get another canary once a day; first payments fill the gap. +Schedule::command('wallet-connections:learn-payees') + ->dailyAt('04:10') + ->withoutOverlapping() + ->runInBackground(); + Schedule::command('model:prune', ['--model' => [EmailVerificationChallenge::class]]) ->hourly() ->withoutOverlapping(); diff --git a/tests/Feature/PayeeAttestationTest.php b/tests/Feature/PayeeAttestationTest.php new file mode 100644 index 00000000..3afdc567 --- /dev/null +++ b/tests/Feature/PayeeAttestationTest.php @@ -0,0 +1,269 @@ + BOLT11s reported as settled payments on invoice "paid-1". */ + private array $paidInvoices = [Bolt11Test::SPEC_DONATION]; + + private bool $faked = false; + + /** @var list */ + private array $archived = []; + + private function fakeBtcPay(): void + { + if ($this->faked) { + return; + } + $this->faked = true; + Http::fake(function (Request $request) { + $url = $request->url(); + if ($request->method() === 'POST' && preg_match('#/stores/[^/]+/invoices$#', $url)) { + return Http::response(['id' => 'canary-1', 'status' => 'New'], 200); + } + if ($request->method() === 'DELETE' && preg_match('#/invoices/([^/]+)$#', $url, $m)) { + $this->archived[] = $m[1]; + + return Http::response([], 200); + } + if (preg_match('#/invoices/canary-1/payment-methods#', $url)) { + return Http::response($this->canaryInvoice === null ? [] : [ + ['paymentMethodId' => 'BTC-LN', 'destination' => $this->canaryInvoice, 'payments' => []], + ], 200); + } + if (preg_match('#/invoices/paid-1/payment-methods#', $url)) { + return Http::response([ + ['paymentMethodId' => 'BTC-CHAIN', 'destination' => 'bc1qxyz', 'payments' => [], 'rate' => '60000'], + [ + '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), + ], + ], 200); + } + if (preg_match('#/invoices/paid-1$#', $url)) { + return Http::response(['id' => 'paid-1', 'status' => 'Settled', 'currency' => 'EUR', 'amount' => '1'], 200); + } + if (str_contains($url, '/payment-methods')) { + return Http::response([ + ['paymentMethodId' => 'BTC-LN', 'enabled' => true, 'config' => ['connectionString' => self::SECRET]], + ], 200); + } + + return Http::response([], 200); + }); + } + + /** @return array{0: User, 1: Store, 2: WalletConnection} */ + private function connectedStore(): array + { + $user = User::factory()->create(); + $store = Store::factory()->create(['user_id' => $user->id, 'wallet_type' => 'blink']); + $connection = WalletConnection::create([ + 'store_id' => $store->id, + 'type' => 'blink', + 'encrypted_secret' => Crypt::encryptString(self::SECRET), + 'status' => 'connected', + 'submitted_by_user_id' => $user->id, + ]); + + return [$user, $store, $connection]; + } + + #[Test] + public function baseline_learns_the_payee_from_a_canary_invoice_and_archives_it(): void + { + $this->fakeBtcPay(); + [$user, , $connection] = $this->connectedStore(); + + $this->assertTrue(app(WalletConfigIntegrityService::class)->baseline($connection, $user)); + + $fresh = $connection->fresh(); + $this->assertSame([Bolt11Test::SPEC_PAYEE], $fresh->payee_pubkeys); + $this->assertSame('canary', $fresh->payee_learn_source); + $this->assertNotNull($fresh->payee_learned_at); + $this->assertSame(['canary-1'], $this->archived, 'the canary invoice is archived right after reading'); + $this->assertDatabaseHas('audit_logs', ['action' => 'wallet_connection.payee_learned', 'target_id' => $connection->id]); + Http::assertSent(fn (Request $r) => $r->method() === 'POST' && str_ends_with($r->url(), '/invoices') + && ($r['metadata']['satflux_canary'] ?? false) === true && $r['currency'] === 'BTC'); + } + + #[Test] + public function without_a_canary_the_first_settled_payment_is_trusted(): void + { + $this->fakeBtcPay(); + $this->canaryInvoice = null; + [$user, $store, $connection] = $this->connectedStore(); + app(WalletConfigIntegrityService::class)->baseline($connection, $user); + $this->assertNull($connection->fresh()->payee_pubkeys); + + app(SettlementLedgerService::class)->syncInvoice($store, 'paid-1'); + + $fresh = $connection->fresh(); + $this->assertSame([Bolt11Test::SPEC_PAYEE], $fresh->payee_pubkeys); + $this->assertSame('first_payment', $fresh->payee_learn_source); + } + + #[Test] + public function a_payment_signed_by_another_node_raises_a_security_incident_once(): void + { + Notification::fake(); + $this->fakeBtcPay(); + [$user, $store, $connection] = $this->connectedStore(); + $admin = User::factory()->admin()->create(); + app(WalletConfigIntegrityService::class)->baseline($connection, $user); + + // Attacker's invoice gets paid. + $this->paidInvoices = [Bolt11Test::OTHER_INVOICE]; + app(SettlementLedgerService::class)->syncInvoice($store, 'paid-1'); + + $fresh = $connection->fresh(); + $this->assertNotNull($fresh->payee_mismatch_at); + $this->assertSame(Bolt11Test::OTHER_PAYEE, $fresh->payee_mismatch_details['pubkey']); + $this->assertSame('paid-1', $fresh->payee_mismatch_details['invoice_id']); + $this->assertSame([Bolt11Test::SPEC_PAYEE], $fresh->payee_mismatch_details['expected']); + $this->assertDatabaseHas('audit_logs', ['action' => 'wallet_connection.payee_mismatch', 'target_id' => $connection->id]); + + $merchantMessage = UserMessage::where('user_id', $user->id)->where('type', 'security')->first(); + $this->assertNotNull($merchantMessage); + $this->assertStringContainsString('received by node 029fc62178…883826', $merchantMessage->body); + $this->assertStringContainsString('Check your wallet balance', $merchantMessage->body); + $this->assertDatabaseHas('user_messages', ['user_id' => $admin->id, 'type' => 'security']); + Notification::assertSentTo($user, WalletPayeeMismatchNotification::class); + + // Same invoice synced again (webhook retry): no second incident. + 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()); + + // Payments to the known node keep passing while the incident is open. + $this->paidInvoices = [Bolt11Test::SPEC_DONATION]; + $result = app(PayeeAttestationService::class)->attestInvoice($store, 'paid-1', [ + ['paymentMethodId' => 'BTC-LN', 'destination' => Bolt11Test::SPEC_DONATION, 'payments' => [['destination' => Bolt11Test::SPEC_DONATION]]], + ]); + $this->assertSame(['ok'], array_values($result)); + $this->assertNotNull($connection->fresh()->payee_mismatch_at); + } + + #[Test] + public function reconnecting_the_wallet_relearns_the_payee_and_closes_the_incident(): void + { + Notification::fake(); + $this->fakeBtcPay(); + [$user, $store, $connection] = $this->connectedStore(); + 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); + + // Merchant moved to the other provider and reconnected: the canary now comes from that node. + $this->canaryInvoice = Bolt11Test::OTHER_INVOICE; + app(WalletConfigIntegrityService::class)->baseline($connection->fresh(), $user, 'connected'); + + $fresh = $connection->fresh(); + $this->assertNull($fresh->payee_mismatch_at); + $this->assertSame([Bolt11Test::OTHER_PAYEE], $fresh->payee_pubkeys); + } + + #[Test] + public function admin_can_accept_a_node_relearn_and_sees_payee_incidents(): void + { + Notification::fake(); + $this->fakeBtcPay(); + [$user, $store, $connection] = $this->connectedStore(); + app(WalletConfigIntegrityService::class)->baseline($connection, $user); + $this->paidInvoices = [Bolt11Test::OTHER_INVOICE]; + app(SettlementLedgerService::class)->syncInvoice($store, 'paid-1'); + + $admin = User::factory()->admin()->create(); + $this->actingAs($admin)->getJson('/api/admin/wallet-changes/drifts') + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.payee_mismatch_details.pubkey', Bolt11Test::OTHER_PAYEE) + ->assertJsonPath('data.0.payee_pubkeys.0', Bolt11Test::SPEC_PAYEE); + + $this->postJson("/api/admin/wallet-connections/{$connection->id}/accept-payee", ['pubkey' => 'nope']) + ->assertStatus(422); + $this->postJson("/api/admin/wallet-connections/{$connection->id}/accept-payee", ['pubkey' => Bolt11Test::OTHER_PAYEE]) + ->assertStatus(200) + ->assertJsonPath('data.payee_pubkeys.1', Bolt11Test::OTHER_PAYEE); + + $fresh = $connection->fresh(); + $this->assertNull($fresh->payee_mismatch_at); + $this->assertSame([Bolt11Test::SPEC_PAYEE, Bolt11Test::OTHER_PAYEE], $fresh->payee_pubkeys); + $this->assertDatabaseHas('audit_logs', ['action' => 'wallet_connection.payee_accepted', 'user_id' => $admin->id]); + $this->getJson('/api/admin/wallet-changes/drifts')->assertJsonCount(0, 'data'); + + $this->postJson("/api/admin/wallet-connections/{$connection->id}/learn-payee") + ->assertStatus(200) + ->assertJsonPath('data.payee_pubkeys', [Bolt11Test::SPEC_PAYEE]); + + $actions = array_column($this->getJson('/api/admin/wallet-changes?store_id='.$store->id)->json('data'), 'action'); + $this->assertContains('wallet_connection.payee_mismatch', $actions); + $this->assertContains('wallet_connection.payee_accepted', $actions); + $this->assertContains('wallet_connection.payee_learned', $actions); + + // Support cannot. + $this->actingAs(User::factory()->support()->create()) + ->postJson("/api/admin/wallet-connections/{$connection->id}/accept-payee", ['pubkey' => Bolt11Test::OTHER_PAYEE]) + ->assertStatus(403); + } + + #[Test] + public function learn_payees_command_fills_missing_allow_lists_only(): void + { + $this->fakeBtcPay(); + [, , $withList] = $this->connectedStore(); + $withList->forceFill(['payee_pubkeys' => [Bolt11Test::OTHER_PAYEE], 'payee_learn_source' => 'canary', 'payee_learned_at' => now()])->save(); + [, , $without] = $this->connectedStore(); + + $this->artisan('wallet-connections:learn-payees') + ->expectsOutputToContain('learned: 1, skipped: 0') + ->assertExitCode(0); + + $this->assertSame([Bolt11Test::OTHER_PAYEE], $withList->fresh()->payee_pubkeys, 'existing lists are left alone'); + $this->assertSame([Bolt11Test::SPEC_PAYEE], $without->fresh()->payee_pubkeys); + } + + #[Test] + public function merchant_endpoint_exposes_the_payee_incident(): void + { + Notification::fake(); + $this->fakeBtcPay(); + [$user, $store, $connection] = $this->connectedStore(); + app(WalletConfigIntegrityService::class)->baseline($connection, $user); + $this->paidInvoices = [Bolt11Test::OTHER_INVOICE]; + app(SettlementLedgerService::class)->syncInvoice($store, 'paid-1'); + + $this->actingAs($user)->getJson("/api/stores/{$store->id}/wallet-connection") + ->assertStatus(200) + ->assertJsonPath('data.payee_mismatch_details.pubkey', Bolt11Test::OTHER_PAYEE); + } +} diff --git a/tests/Unit/Bolt11Test.php b/tests/Unit/Bolt11Test.php new file mode 100644 index 00000000..0cd165c4 --- /dev/null +++ b/tests/Unit/Bolt11Test.php @@ -0,0 +1,217 @@ +assertSame(self::SPEC_PAYEE, $decoded['payee']); + $this->assertSame('recovered', $decoded['payee_source']); + $this->assertSame('bc', $decoded['network']); + } + $this->assertSame(self::OTHER_PAYEE, Bolt11::payee(self::OTHER_INVOICE)); + } + + #[Test] + public function parses_amount_description_and_payment_hash(): void + { + $donation = Bolt11::decode(self::SPEC_DONATION); + $this->assertNull($donation['amount_msat']); + $this->assertSame('Please consider supporting this project', $donation['description']); + $this->assertSame('0001020304050607080900010203040506070809000102030405060708090102', $donation['payment_hash']); + + $coffee = Bolt11::decode(self::SPEC_COFFEE); + $this->assertSame(250_000_000, $coffee['amount_msat']); + $this->assertSame(60, $coffee['expiry']); + + $this->assertSame(2_000_000_000, Bolt11::decode(self::SPEC_FALLBACK)['amount_msat']); + // 9678785340 pico-BTC = 0.00967878534 BTC = 967 878 534 msat (p is 10^-12). + $pico = Bolt11::decode(self::SPEC_PICO); + $this->assertSame(967_878_534, $pico['amount_msat']); + $this->assertSame(self::SPEC_PAYEE, $pico['payee']); + $this->assertSame(547_000, Bolt11::decode(self::OTHER_INVOICE)['amount_msat']); + } + + #[Test] + public function an_explicit_payee_field_is_only_accepted_when_it_is_the_signer(): void + { + $privateKey = str_repeat('11', 32); + $pubkey = self::compressedPubkey($privateKey); + + $signed = self::buildInvoice($privateKey, $pubkey); + $decoded = Bolt11::decode($signed); + $this->assertSame($pubkey, $decoded['payee']); + $this->assertSame('field', $decoded['payee_source']); + $this->assertSame(self::compressedPubkey($privateKey), Bolt11::decode(self::buildInvoice($privateKey, null))['payee']); + + // Valid checksum, but `n` names a node that did not sign the invoice. + $forged = self::buildInvoice($privateKey, self::SPEC_PAYEE); + $this->assertNull(Bolt11::payee($forged)); + $this->expectException(\InvalidArgumentException::class); + Bolt11::decode($forged); + } + + #[Test] + public function accepts_uppercase_and_lightning_prefix_and_rejects_garbage(): void + { + $this->assertSame(self::SPEC_PAYEE, Bolt11::payee(strtoupper(self::SPEC_COFFEE))); + $this->assertSame(self::SPEC_PAYEE, Bolt11::payee('lightning:'.self::SPEC_COFFEE)); + + $this->assertNull(Bolt11::payee('bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4')); + $this->assertNull(Bolt11::payee(substr(self::SPEC_COFFEE, 0, -1).'a'), 'checksum failure'); + $this->assertNull(Bolt11::payee('')); + $this->assertNull(Bolt11::payee('lnbc1')); + } + + /** + * Minimal BOLT11 encoder for fixtures: lnbc, amount-less, one payment hash, + * optional `n`, signed by $privateKey (hex) with a recoverable signature. + */ + private static function buildInvoice(string $privateKey, ?string $nPubkey): string + { + $hrp = 'lnbc'; + $words = self::intToWords(1_700_000_000, 7); + $paymentHash = str_repeat("\x42", 32); + $words = [...$words, 1, 1, 20, ...self::bytesToWords($paymentHash)]; // p, len 52 + if ($nPubkey !== null) { + $words = [...$words, 19, 1, 21, ...self::bytesToWords(hex2bin($nPubkey))]; // n, len 53 + } + $preimage = $hrp.self::wordsToBytesPadded($words); + $hash = hash('sha256', $preimage); + $signature = (new Secp256k1)->sign($hash, $privateKey); + $r = str_pad(gmp_strval($signature->getR(), 16), 64, '0', STR_PAD_LEFT); + $s = str_pad(gmp_strval($signature->getS(), 16), 64, '0', STR_PAD_LEFT); + $sigBytes = hex2bin($r.$s).chr($signature->getRecoveryParam()); + $words = [...$words, ...self::bytesToWords($sigBytes)]; // 65 bytes = 104 words exactly + + return self::bech32Encode($hrp, $words); + } + + private static function compressedPubkey(string $privateKey): string + { + $generator = EccFactory::getSecgCurves()->generator256k1(); + $point = $generator->mul(gmp_init($privateKey, 16)); + + return (gmp_cmp(gmp_mod($point->getY(), 2), 0) === 0 ? '02' : '03').str_pad(gmp_strval($point->getX(), 16), 64, '0', STR_PAD_LEFT); + } + + /** @return list */ + private static function intToWords(int $value, int $count): array + { + $out = []; + for ($i = $count - 1; $i >= 0; $i--) { + $out[] = ($value >> (5 * $i)) & 31; + } + + return $out; + } + + /** @return list */ + private static function bytesToWords(string $bytes): array + { + $acc = 0; + $bits = 0; + $out = []; + foreach (str_split($bytes) as $byte) { + $acc = (($acc << 8) | ord($byte)) & 0xFFFFFFFF; + $bits += 8; + while ($bits >= 5) { + $bits -= 5; + $out[] = ($acc >> $bits) & 31; + } + } + if ($bits > 0) { + $out[] = ($acc << (5 - $bits)) & 31; + } + + return $out; + } + + /** @param list $words */ + private static function wordsToBytesPadded(array $words): string + { + $acc = 0; + $bits = 0; + $out = ''; + foreach ($words as $w) { + $acc = (($acc << 5) | $w) & 0xFFFFFFFF; + $bits += 5; + while ($bits >= 8) { + $bits -= 8; + $out .= chr(($acc >> $bits) & 0xFF); + } + } + if ($bits > 0) { + $out .= chr(($acc << (8 - $bits)) & 0xFF); + } + + return $out; + } + + /** @param list $words */ + private static function bech32Encode(string $hrp, array $words): string + { + $charset = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'; + $expand = []; + foreach (str_split($hrp) as $c) { + $expand[] = ord($c) >> 5; + } + $expand[] = 0; + foreach (str_split($hrp) as $c) { + $expand[] = ord($c) & 31; + } + $values = [...$expand, ...$words, 0, 0, 0, 0, 0, 0]; + $chk = 1; + $gen = [0x3B6A57B2, 0x26508E6D, 0x1EA119FA, 0x3D4233DD, 0x2A1462B3]; + foreach ($values as $v) { + $top = $chk >> 25; + $chk = (($chk & 0x1FFFFFF) << 5) ^ $v; + for ($i = 0; $i < 5; $i++) { + if (($top >> $i) & 1) { + $chk ^= $gen[$i]; + } + } + } + $polymod = $chk ^ 1; + $checksum = []; + for ($i = 0; $i < 6; $i++) { + $checksum[] = ($polymod >> (5 * (5 - $i))) & 31; + } + $out = $hrp.'1'; + foreach ([...$words, ...$checksum] as $w) { + $out .= $charset[$w]; + } + + return $out; + } +}