Skip to content

The security & trust model

A gateway moves real money with the operator's processor credentials, so Core enforces trust rather than assuming it — the signing scheme, hardened install, the three-gate load, and the payment rule to verify or fail closed.

14 min readUpdated Aug 17, 2026
On this page

A payment-gateway extension is not data — it is executable PHP that talks to a payment processor with your operator's credentials and moves real money: it opens charges, captures orders, issues refunds, and settles deposits into wallet balances and invoices. That is a lot of trust to hand a third party, so Core does not extend it on faith. Two classes stand between a gateway row and a live driver, and they run on every resolve — not once at install. App\Services\GatewayResolver applies the operator's containment switch first; then App\Services\Gateways\GatewayRegistry refuses to load unless three independent conditions hold. This article explains those gates, the signing scheme behind them, the extra containment lever payments get that panels and registrars do not, why a folder someone copies onto the box is inert, and the handful of author habits that follow directly from how the model works.

If nothing runnable resolves, the resolver returns NullGateway — a checkout, settlement, or refund fails safely instead of executing an unverified or unlicensed gateway. Core ships no built-in automatic gateways at all, so a slug with no installed, enabled extension resolves to the null driver. (Manual/offline gateways have no driver and are settled by hand in the admin — they never reach this resolver.)

Why a gateway is trusted code, not trusted data#

A theme is verified once and then treated as inert data. A gateway is different: it is code that executes, and the operation it executes — accepting money and crediting it — is not something you can quietly walk back. So Core keeps the trust material around after install — the signed artifact itself, the signature headers, and a per-file hash manifest — and re-verifies against them every time the driver loads. The gate is not a one-time install check you can slip past afterward; it runs on the path that turns a database row into a live object, so there is no other way to get a driver instance.

The bias is fail-closed on money, and NullGateway states it plainly:

php
class NullGateway extends AbstractGateway
{
    public function capabilities(): array
    {
        return [];
    }
}

Every operation reports a safe failure and it declares no capabilities, so checkout/settlement/refund calls fail cleanly. That direction is deliberate: a gateway that quietly settled a forged payment would be unrecoverable, whereas declining an in-flight payment leaves the deposit in the ledger for an operator to reconcile by hand.

The signing scheme#

On approval the marketplace signs the artifact with an Ed25519 key, producing two signatures that answer two different questions. The artifact signature is over "salieno.marketplace.artifact/1\0" || sha256(zip) — "are these the exact bytes Salieno approved". The domain tag is not decoration: without it the signature could be replayed into another Salieno protocol that signs a raw digest, and the Core updater is one. The descriptor signature is over the canonical descriptor JSON (schema salieno.marketplace.version/1) — "and is this what it claims to be": which product, which version, which Core window. Because the descriptor pins the artifact's digest, the two signatures lock together — an approved descriptor cannot be paired with a different artifact, and a genuine artifact cannot be passed off as a different product or version.

Core verifies both against a pinned public key (rk-b4425f5d) baked into config/licensing.php. The key is never read from the response — a key the server hands over is one an attacker who can answer as the server has just minted — so a build signed by any other key is rejected. This is why there is no gateway upload: an operator cannot produce the signature the marketplace applies.

The three gates#

Every resolve runs all three, in order. Each is independent: passing one tells you nothing about the others. driverFor() starts them from a single lookup:

php
$ext = GatewayExtension::where('slug', $slug)->enabled()->first();

return $ext !== null ? $this->loadExtensionDriver($ext, $forCallback) : null;

Gate 1 — REGISTERED#

There must be an enabled `gateway_extensions` row for the slug. That row is written only by the installer, at the end of a signed marketplace install. A directory someone drops onto the server has no row, so there is nothing to resolve and nothing to load. A disabled row is treated as absent — it will not resolve, and it will not even leak its credential profile to the Auto Gateways config form (manifestOf() is scoped to enabled rows for exactly that reason).

Gate 2 — GENUINE#

The stored artifact must re-verify against the pinned marketplace key, and each on-disk class file's SHA-256 must match the hash recorded from that verified artifact at install. This gate is always strict — gateway code moves money — and it runs on every path, checkout and callback alike:

php
$verified = app(MarketplaceSignature::class)->verify($ext->signatureHeaders(), $ext->artifactPath());
if (! ($verified['ok'] ?? false)) {
    return $this->deny($ext, 'signature_'.($verified['error'] ?? 'invalid'));
}

Because the key is pinned, you cannot forge a signature for it — which closes off hand-written drivers. The second half closes off tampering after install. When the installer unpacks a verified artifact it records relative-path => sha256 for every file, and the scoped autoloader consults that map before it requires anything:

php
// Bind loaded bytes to the verified artifact: refuse a class file edited after install.
$expected = $map['files'][$relative] ?? null;
if (is_string($expected) && $expected !== '' && ! hash_equals($expected, hash_file('sha256', $file))) {
    Log::warning('Gateway extension class file failed its hash check.', ['slug' => $slug, 'file' => $relative]);
    return;
}

require $file;

Edit a class file on disk and its hash no longer matches — the file will not load. The code that runs is exactly the code the marketplace signed, byte for byte. That verification rests on hardened extraction at install time: the installer rejects symlinks, refuses any entry that escapes its directory (../ or an absolute path), and caps both the entry count and the uncompressed size, so a malicious archive cannot plant files outside the extension or exhaust the disk before it is ever recorded.

Gate 3 — ENTITLED#

The activated licence must still own this gateway, and the marketplace decides that — not anything on the box:

php
$owns = app(MarketplaceClient::class)->ownsGateway($ext->slug);

if ($owns === true) {
    $ext->forceFill(['entitlement_checked_at' => now()])->saveQuietly();
    return true;
}

if ($owns === false) {
    return false; // marketplace answered: not owned
}

// $owns === null → unreachable. Trust a recent confirmation, then fail closed.
$graceHours = (int) config('licensing.entitlement_grace_hours', 72);
return $ext->entitlement_checked_at !== null
    && $ext->entitlement_checked_at->gt(now()->subHours($graceHours));

A definite yes refreshes the confirmation timestamp; a definite no denies immediately; an unreachable marketplace is trusted for a grace window (72 hours by default), then fails closed. Install itself is proof of entitlement at that moment, because the single-use grant the installer consumes is only ever issued to an owner — an active entitlement plus an active licence for the same customer.

But payments bend this gate one way that registrars do not: entitlement is asymmetric. On checkout it fails closed — an unlicensed gateway cannot start new payments. On an inbound callback it is lenient, because a webhook or return only exists for a deposit that was entitled when the buyer began paying, and the money has already moved. Refusing to settle over a lapsed licence would strand a confirmed payment, so Core logs it and loads anyway:

php
if (! $this->entitlementOk($ext)) {
    if ($forCallback) {
        Log::warning('Gateway entitlement unconfirmed on a payment callback — loading anyway to settle a confirmed payment.', ['slug' => $ext->slug]);
        // fall through: the payment already moved; the signature+hash gate has passed.
    } else {
        return $this->deny($ext, 'not_entitled');
    }
}

The relaxation is narrow and surgical: only the entitlement gate softens, and only on the callback path. Gate 2 stays strict there — it is local, cheap, and it is the check that answers "is this the code we approved". You never settle money through an unverified driver; you only decline to hold a confirmed payment hostage to a billing lapse.

The containment lever: a disabled gateway is NullGateway#

There is one thing signature verification cannot help with. If an operator believes a gateway's processor credentials have been compromised, the attacker holds a genuine key — the code is authentic and the signature proves nothing about who is using it. So payments get a lever the registry sits below: the operator's Gateway.status switch, enforced a level up in GatewayResolver, before the three-gate registry ever runs, on both checkout and callbacks:

php
// (1) CONTAINMENT — off, or manual/offline (no driver) → nothing runnable.
if ($gateway === null || ! $gateway->status || ! $gateway->isAutomatic()) {
    return new NullGateway();
}

Switching a gateway off is absolute: no new checkout can start through it, and no inbound webhook or return can settle through it either — both resolve to NullGateway. That is the containment property that matters when credentials leak. As an author, respect it by never trying to route around a disabled state; the resolver is the only seam, and it has already decided.

Why a copied folder cannot run#

Put the gates together and the answer falls out. Take a perfectly genuine, signed Stripe folder off one server and drop it onto another install:

  • No row. Nothing wrote a gateway_extensions record, so Gate 1 finds nothing to resolve.
  • No entitlement. Even if you manufactured a row, the second install's licence never bought that gateway, so on checkout the marketplace answers not owned and Gate 3 denies it.

You cannot fake your way past Gate 2 either — you cannot forge the signature, and you cannot edit the extracted code without breaking its hash. There is no combination of file operations that turns a copied folder into a running gateway.

Where installed code lives#

Installed extensions unpack to storage/app/gateways/{slug}/ — a runtime artifact that is gitignored and not on Composer's normal autoload path. Dropping classes there loads nothing on its own: no standard autoloader is looking in that directory.

The only thing that loads a gateway class is GatewayRegistry's scoped autoloader, registered by GatewayServiceProvider and appended (never prepended) so a real class never resolves through it by accident. It refuses to load anything until the full gate has run and unlocked the slug, and then it loads only classes that belong to that unlocked extension's namespace and whose file hash matches the recorded value. A slug that has not passed the gate is not in the unlock list, so its namespace is never even considered. Loading is gated at both ends: nothing generic can trigger it, and the bytes it loads are pinned to the verified artifact.

What this means for you as an author#

The model is strict on purpose, and a few author habits follow directly from it.

Hold no per-payment state — the driver is stateless#

Core instantiates your driver once, with no constructor, and passes the Deposit into every method. That is deliberate isolation: because the driver carries nothing between calls, one buyer's payment cannot leak into another's. Read your credentials and context per call from the deposit's config — $this->getParam($deposit, 'secret_key'), $this->isSandbox($deposit) — and for the webhook, which arrives with no deposit, read your global secret with $this->ownConfig(). Never stash anything on $this. Writing into your own folder fights the system twice over: a modified file fails the hash check, and an update replaces the whole directory atomically, discarding whatever you wrote. Persist state on what Core hands you — the Deposit model, whose btc_wallet field holds the gateway's own reference and whose detail field holds the verified proof, both ->save()-able.

Never leak credentials#

The operator's processor credentials live in gateway_currencies.gateway_parameter, cast encrypted:object and hidden from serialization, logs, and Livewire snapshots — so a stack trace or a component payload never carries a live key. Keep it that way. Declare secret fields as type: "password" in your manifest's credentials block so Core masks them in the form (see Payment credentials & the gateway config). Never hardcode a secret, never log one, and never echo one back in a message. Processor APIs fail with raw text full of account identifiers and sometimes token fragments — surface the real processor error so the operator can act on it, but strip the credentials out first.

Declare capabilities honestly#

capabilities() is a security boundary as much as a UI one. The manifest's capabilities gate the UI cheaply, without loading the driver, and the driver's capabilities() is the runtime authority — the two must be identical. Declaring something you cannot honor produces a control that fails when the operator clicks it: declare refund and Core routes the admin's "refund to gateway" button at your refund() method instead of crediting the wallet; declare recurring and Core will drive automatic renewals through you. A gateway that only takes one-off charges declares just charge (plus webhook/redirect as it uses them) and lets Core handle refunds by wallet credit — first-class, not second-rate. A padded declaration is a broken one.

Verify authenticity, or fail closed#

ipn() runs with no CSRF and no auth — it is a public endpoint that anyone can POST to. Its first job is to prove the request really came from the processor, and it must fail closed if it cannot. The trap that catches people is the empty signing secret: an empty-key HMAC will happily validate an attacker-forged event, settling any deposit without a cent changing hands. Reject a missing secret before you verify anything:

php
$secret = $this->webhookSecret();
if (! $secret) {
    return $this->ipnReject('Webhook signing secret is not configured');
}

try {
    $event = \Stripe\Webhook::constructEvent($payload, $sig, $secret);
} catch (\Stripe\Exception\SignatureVerificationException $e) {
    return $this->ipnReject('Invalid signature');
}

The same rule covers a browser return, which is even easier to spoof — a return URL is just a link the buyer can replay. PayPal does not trust the return; it re-asks PayPal for the order's true state before it captures:

php
// Verify the order's true state with PayPal before capturing — guards a spoofed/replayed
// return_url hit for an unapproved or already-handled order.
$order = $provider->showOrderDetails($orderId);
$status = $order['status'] ?? null;

Never throw out of a public method either: an uncaught exception escaping ipn() is a failure Core cannot shape into a safe response. Catch it, return ipnReject(...) (webhook) or returnFailed($deposit, ...) (browser), and let the buyer land somewhere sane.

Never settle in the driver#

This is the most important rule in the model. A driver never credits balance or marks an invoice paid. Once you have verified the event, store the proof and hand the deposit to $this->settle():

php
if ($paid && in_array((int) $deposit->status, [Status::PAYMENT_INITIATE, Status::PAYMENT_PENDING], true)) {
    $deposit->detail = $session;   // proof of payment (carries payment_intent for refunds)
    $deposit->save();
    $this->settle($deposit);       // Core's idempotent, row-locked settlement boundary
}

settle() routes to Core's single idempotent, row-locked boundary, which credits exactly once no matter how many times a webhook and a browser-return both fire for the same payment. A driver that credited the wallet itself would double-credit the moment two signals arrive — and for a hosted flow, two signals is the normal case, not the edge. Your job ends at verified proof, saved, handed off. The money boundary is Core's.

In short#

Core will run your code because three things are simultaneously true: it is registered from a signed install, it is genuine and unmodified, and the licence still owns it — with the entitlement half relaxed just enough that a confirmed payment still settles, and never at the cost of the signature check. Above all that sits the operator's off switch, which turns any gateway into NullGateway on both checkout and callback the instant its credentials are in doubt. That safety is what lets a hosting operator install a third-party gateway without auditing its source — you cannot ship a backdoor that survives review, signing, and the hash gate, you cannot run unentitled, and Core hides every control your capabilities() does not declare. Your part of the bargain is to stay a clean, stateless artifact — hold no state, leak no credentials, declare only what you do, verify every callback or fail closed, and settle only through $this->settle(). When you are ready to ship, Publishing and updates walks through the signed install and update path that these gates enforce.

Was this article helpful?
Still stuck?Contact support
The security & trust model · Salieno Docs