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:
OPCUA_TRUST_STORE_PATH=/var/lib/opcua/trust
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:
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:
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:
#[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:
- Server-side: generate new cert. Install it. Most servers support multiple valid certs.
- Symfony-side: pin the new cert (companion CLI or your
app:trust:add). - Switch over server-side: server starts presenting the new cert. Both are trusted.
- Symfony-side: remove the old fingerprint.
- Confirm via
FileTrustStore::list().
CI / deployment
Drive trust pinning from your deploy pipeline via the companion CLI:
- 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:
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
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.
Where to read next
You've finished Security. Next: Testing · PHPUnit and Pest setup.