Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions app/Console/Commands/LearnWalletPayees.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

namespace App\Console\Commands;

use App\Models\WalletConnection;
use App\Services\WalletSecurity\PayeeAttestationService;
use Illuminate\Console\Command;

class LearnWalletPayees extends Command
{
protected $signature = 'wallet-connections:learn-payees
{--all : Relearn every connected wallet, not only those without an allow-list}
{--store= : One store (local UUID)}';

protected $description = 'Learn the Lightning payee node of connected wallets from a canary invoice (payee attestation allow-list)';

public function handle(PayeeAttestationService $payees): int
{
$query = WalletConnection::query()->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;
}
}
31 changes: 29 additions & 2 deletions app/Http/Controllers/Admin/WalletChangeLogController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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',
];

Expand Down Expand Up @@ -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 = [];
Expand All @@ -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,
];
}

Expand All @@ -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
{
Expand Down
4 changes: 4 additions & 0 deletions app/Http/Controllers/WalletConnectionController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
],
]);
}
Expand Down
14 changes: 14 additions & 0 deletions app/Models/WalletConnection.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@
* @property array<string, mixed>|null $config_snapshot
* @property Carbon|null $config_verified_at
* @property Carbon|null $drift_detected_at
* @property list<string>|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<string>, seen_at: string}|null $payee_mismatch_details
* @property array{changed: string[], added: string[], removed: string[], details?: array<string, array{expected: string|null, actual: string|null}>}|null $drift_details
*/
class WalletConnection extends Model
Expand Down Expand Up @@ -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',
];

/**
Expand All @@ -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',
];
}

Expand Down
57 changes: 57 additions & 0 deletions app/Notifications/WalletPayeeMismatchNotification.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?php

namespace App\Notifications;

use App\Models\Store;
use App\Models\WalletConnection;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;

/**
* Merchant e-mail: a settled Lightning payment was signed by a node that is
* not the one the connected wallet uses (PayeeAttestationService).
*/
class WalletPayeeMismatchNotification extends Notification
{
use Queueable;

/** @param array{pubkey: string, invoice_id: string|null, method: string|null, expected: list<string>, 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,
];
}
}
9 changes: 9 additions & 0 deletions app/Services/Boltz/SettlementLedgerService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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)) {
Expand Down
12 changes: 12 additions & 0 deletions app/Services/BtcPay/InvoiceService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading