symfony-opcua · master
Docs · Security

Trust store

Managing pinned OPC UA server certificates. The trust-store policy choice, file-system layout, and the companion CLI / programmatic helpers for add / list / remove.

The mirror image of client_certificate. The trust store is a local directory of OPC UA server certificates your Symfony app considers legitimate. The underlying opcua-client library consults it on every secure-channel handshake.

The directory

OS Default
POSIX ~/.opcua/
Windows %APPDATA%\opcua\
Override OPCUA_TRUST_STORE_PATH env (wired in YAML)

For production, override to a known location:

bash .env
OPCUA_TRUST_STORE_PATH=/var/lib/opcua/trust
text bundle config
php_opcua_symfony_opcua:
    connections:
        default:
            trust_store_path: '%env(OPCUA_TRUST_STORE_PATH)%'
            trust_policy:     '%env(OPCUA_TRUST_POLICY)%'
            auto_accept:      '%env(bool:OPCUA_AUTO_ACCEPT)%'

Trust policies

Three policies, three trade-offs:

Policy Checked on connect Pro Con
fingerprint SHA-1 of cert is in trust store Simplest. No CA chain Rotation = manual repinning
fingerprint+expiry Fingerprint match + cert NotAfter is in the future Catches expiry-related issues Same rotation cost
full Full X.509 chain validation against trust store as CAs CA-based rotation = no per-server changes Need a CA infrastructure

FileTrustStore fingerprints with SHA-1 of the DER-encoded certificate, not SHA-256.

For 1-50 PLCs: fingerprint+expiry. For 50+ behind a CA: full.

Adding a server cert

This bundle does not ship opcua:trust:add / opcua:trust:list / opcua:trust:remove Symfony commands. Use one of:

Option 1 — The opcua-cli companion package

vendor/bin/opcua trust:add opc.tcp://plc.factory.local:4840

The CLI fetches the server cert, prints subject / SAN / SHA-1 fingerprint / expiry, asks for confirmation, and writes the cert to the trust-store directory.

Option 2 — A programmatic Symfony command

FileTrustStore (from opcua-client) exposes the underlying API. Wrap it in an app:trust:add command of your own:

php src/Command/TrustAddCommand.php
namespace App\Command;

use PhpOpcua\Client\ClientBuilder;
use PhpOpcua\Client\TrustStore\FileTrustStore;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\{InputArgument, InputInterface};
use Symfony\Component\Console\Output\OutputInterface;

#[AsCommand(name: 'app:trust:add')]
final class TrustAddCommand extends Command
{
    protected function configure(): void
    {
        $this->addArgument('endpoint', InputArgument::REQUIRED);
        $this->addArgument('store',    InputArgument::OPTIONAL, '', getenv('HOME') . '/.opcua');
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $endpoint = (string) $input->getArgument('endpoint');
        $store    = (string) $input->getArgument('store');

        // Probe with a None-secured discovery to grab the server cert.
        $probe = ClientBuilder::create()->connect($endpoint);
        $der   = $probe->getServerCertificate();   // exact accessor: see opcua-client
        $probe->disconnect();

        (new FileTrustStore($store))->trust($der);

        $output->writeln("Pinned server cert from $endpoint into $store");
        return Command::SUCCESS;
    }
}

Adapt to taste (subject / expiry preview, confirmation prompt, JSON output, multi-tenant store path).

Listing / removing pinned certs

Same shape — call FileTrustStore::list() / ::untrust($der) from your own command, or use the opcua-cli companion.

TOFU mode (auto_accept)

For development:

bash .env (dev)
OPCUA_AUTO_ACCEPT=true

First connection: cert auto-pinned, connection succeeds, opcua-client dispatches a PhpOpcua\Client\Event\ServerCertificateAutoAccepted event. Subsequent connections validate against the pinned cert.

Note

auto_accept: true in production is equivalent to disabling server-cert validation. Anyone who MitMs the first connection establishes a permanent trust. Use only in dev, or behind an admin gate.

For a production-safe variant, a one-time bootstrap command:

php src/Command/TrustBootstrapCommand.php
#[AsCommand(name: 'app:opcua:trust:bootstrap')]
final class TrustBootstrapCommand extends Command
{
    public function __construct(
        private OpcuaManager $opcua,
    ) {
        parent::__construct();
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        // Operator runs this once per server (with auto_accept temporarily on)
        $this->opcua->connect()->read('i=2256');   // forces a connection + cert pin
        $output->writeln('Cert pinned. Verify with vendor/bin/opcua trust:list');
        return Command::SUCCESS;
    }
}

Wrap with auth so only an admin can run it.

Cert rotation procedure

The server's cert expires; service stays up:

  1. Server-side: generate new cert. Install it. Most servers support multiple valid certs.
  2. Symfony-side: pin the new cert (companion CLI or your app:trust:add).
  3. Switch over server-side: server starts presenting the new cert. Both are trusted.
  4. Symfony-side: remove the old fingerprint.
  5. Confirm via FileTrustStore::list().

CI / deployment

Drive trust pinning from your deploy pipeline via the companion CLI:

text deploy step
- name: Pin OPC UA server certs
  run: |
    for endpoint in $(cat ./opcua-endpoints.txt); do
      vendor/bin/opcua trust:add --force "$endpoint"
    done
  env:
    OPCUA_TRUST_STORE_PATH: /var/lib/opcua/trust

--force skips the confirmation. Use only when input is known-good (in repo, not user-provided).

Multi-tenant trust stores

Per-tenant isolation:

text per-tenant trust
php_opcua_symfony_opcua:
    connections:
        plc-tenant-acme:
            trust_store_path: '/var/lib/opcua/trust/acme'
        plc-tenant-globex:
            trust_store_path: '/var/lib/opcua/trust/globex'

A breach of one tenant's trust store doesn't compromise others.

Permissions

bash terminal
sudo mkdir -p /var/lib/opcua/trust
sudo chown www-data:www-data /var/lib/opcua/trust
sudo chmod 0750 /var/lib/opcua/trust

Files inside are mode 0640 — readable by user and group.

What's in a trust-store file

Each pinned cert is a PEM (or DER) file in the configured directory; FileTrustStore walks the directory at validation time and computes the SHA-1 of each cert's DER to compare against the offered server cert.

Cache implications

opcua-client caches trust-store hashes (5-min TTL by default). If you bypass the programmatic API and drop files in manually, either wait for the TTL or flush the cache pool you configured in framework.cache.

You've finished Security. Next: Testing · PHPUnit and Pest setup.

Documentation