<?php
declare(strict_types=1);
use PhpOpcua\Client\Encoding\BinaryDecoder;
use PhpOpcua\Client\Encoding\BinaryEncoder;
use PhpOpcua\Client\Module\History\HistoryReadService;
use PhpOpcua\Client\Module\Subscription\MonitoredItemService;
use PhpOpcua\Client\Module\Subscription\PublishService;
use PhpOpcua\Client\Protocol\MessageHeader;
use PhpOpcua\Client\Protocol\SessionService;
use PhpOpcua\Client\Security\CertificateManager;
use PhpOpcua\Client\Security\SecureChannel;
use PhpOpcua\Client\Security\SecurityMode;
use PhpOpcua\Client\Security\SecurityPolicy;
use PhpOpcua\Client\Types\BuiltinType;
use PhpOpcua\Client\Types\NodeId;
function writeAdditionalResponseHeader(BinaryEncoder $encoder, int $statusCode = 0): void
{
$encoder->writeInt64(0);
$encoder->writeUInt32(1);
$encoder->writeUInt32($statusCode);
$encoder->writeByte(0);
$encoder->writeInt32(0);
$encoder->writeNodeId(NodeId::numeric(0, 0));
$encoder->writeByte(0);
}
function writeAdditionalMessagePrefix(BinaryEncoder $encoder): void
{
$encoder->writeUInt32(1);
$encoder->writeUInt32(1);
$encoder->writeUInt32(1);
}
function writeFullDiagnosticInfo(BinaryEncoder $encoder): void
{
$encoder->writeByte(0x1F);
$encoder->writeInt32(1);
$encoder->writeInt32(2);
$encoder->writeInt32(3);
$encoder->writeString('details');
$encoder->writeUInt32(0x80010000);
}
// ========================================================================
// PublishService: diagnostics + results in response
// ========================================================================
describe('PublishService decode with diagnostics and results', function () {
it('decodes a PublishResponse with Results and DiagnosticInfos', function () {
$session = new SessionService(1, 1);
$service = new PublishService($session);
$encoder = new BinaryEncoder();
writeAdditionalMessagePrefix($encoder);
$encoder->writeNodeId(NodeId::numeric(0, 829));
writeAdditionalResponseHeader($encoder);
// SubscriptionId
$encoder->writeUInt32(1);
// AvailableSequenceNumbers: 0
$encoder->writeInt32(0);
// MoreNotifications
$encoder->writeBoolean(false);
// NotificationMessage
$encoder->writeUInt32(1); // SequenceNumber
$encoder->writeDateTime(null); // PublishTime
// NotificationData: 1 DataChangeNotification with diagnostics inside
$encoder->writeInt32(1);
$encoder->writeNodeId(NodeId::numeric(0, 811));
$encoder->writeByte(0x01);
$bodyEncoder = new BinaryEncoder();
// MonitoredItems: 1
$bodyEncoder->writeInt32(1);
$bodyEncoder->writeUInt32(1);
$bodyEncoder->writeByte(0x01);
$bodyEncoder->writeByte(BuiltinType::Int32->value);
$bodyEncoder->writeInt32(99);
// DiagnosticInfos inside DataChangeNotification: 1
$bodyEncoder->writeInt32(1);
writeFullDiagnosticInfo($bodyEncoder);
$body = $bodyEncoder->getBuffer();
$encoder->writeInt32(strlen($body));
$encoder->writeRawBytes($body);
// Results: 2 status codes
$encoder->writeInt32(2);
$encoder->writeUInt32(0);
$encoder->writeUInt32(0x80010000);
// DiagnosticInfos: 1
$encoder->writeInt32(1);
writeFullDiagnosticInfo($encoder);
$decoder = new BinaryDecoder($encoder->getBuffer());
$result = $service->decodePublishResponse($decoder);
expect($result->subscriptionId)->toBe(1);
expect($result->notifications)->toHaveCount(1);
expect($result->notifications[0]['dataValue']->getValue())->toBe(99);
});
it('decodes a PublishResponse where body consumed less than bodyLength', function () {
$session = new SessionService(1, 1);
$service = new PublishService($session);
$encoder = new BinaryEncoder();
writeAdditionalMessagePrefix($encoder);
$encoder->writeNodeId(NodeId::numeric(0, 829));
writeAdditionalResponseHeader($encoder);
$encoder->writeUInt32(1);
$encoder->writeInt32(0);
$encoder->writeBoolean(false);
$encoder->writeUInt32(1);
$encoder->writeDateTime(null);
// NotificationData: 1 DataChangeNotification with extra padding in body
$encoder->writeInt32(1);
$encoder->writeNodeId(NodeId::numeric(0, 811));
$encoder->writeByte(0x01);
$bodyEncoder = new BinaryEncoder();
$bodyEncoder->writeInt32(1);
$bodyEncoder->writeUInt32(1);
$bodyEncoder->writeByte(0x01);
$bodyEncoder->writeByte(BuiltinType::Int32->value);
$bodyEncoder->writeInt32(55);
$bodyEncoder->writeInt32(0); // DiagnosticInfos
$body = $bodyEncoder->getBuffer();
// Add 8 extra bytes to simulate body that's larger than what we consume
$paddedBody = $body . str_repeat("\x00", 8);
$encoder->writeInt32(strlen($paddedBody));
$encoder->writeRawBytes($paddedBody);
$encoder->writeInt32(0); // Results
$encoder->writeInt32(0); // DiagnosticInfos
$decoder = new BinaryDecoder($encoder->getBuffer());
$result = $service->decodePublishResponse($decoder);
expect($result->notifications)->toHaveCount(1);
expect($result->notifications[0]['dataValue']->getValue())->toBe(55);
});
});
// ========================================================================
// MonitoredItemService: diagnostics
// ========================================================================
describe('MonitoredItemService decode with diagnostics', function () {
it('decodes CreateMonitoredItemsResponse with diagnostics', function () {
$session = new SessionService(1, 1);
$service = new MonitoredItemService($session);
$encoder = new BinaryEncoder();
writeAdditionalMessagePrefix($encoder);
$encoder->writeNodeId(NodeId::numeric(0, 754));
writeAdditionalResponseHeader($encoder);
// Results: 1
$encoder->writeInt32(1);
$encoder->writeUInt32(0);
$encoder->writeUInt32(50);
$encoder->writeDouble(250.0);
$encoder->writeUInt32(2);
$encoder->writeNodeId(NodeId::numeric(0, 0));
$encoder->writeByte(0x00);
// DiagnosticInfos: 1
$encoder->writeInt32(1);
writeFullDiagnosticInfo($encoder);
$decoder = new BinaryDecoder($encoder->getBuffer());
$result = $service->decodeCreateMonitoredItemsResponse($decoder);
expect($result)->toHaveCount(1);
expect($result[0]->monitoredItemId)->toBe(50);
});
it('decodes DeleteMonitoredItemsResponse with diagnostics', function () {
$session = new SessionService(1, 1);
$service = new MonitoredItemService($session);
$encoder = new BinaryEncoder();
writeAdditionalMessagePrefix($encoder);
$encoder->writeNodeId(NodeId::numeric(0, 784));
writeAdditionalResponseHeader($encoder);
// Results: 1
$encoder->writeInt32(1);
$encoder->writeUInt32(0);
// DiagnosticInfos: 1
$encoder->writeInt32(1);
writeFullDiagnosticInfo($encoder);
$decoder = new BinaryDecoder($encoder->getBuffer());
$result = $service->decodeDeleteMonitoredItemsResponse($decoder);
expect($result)->toBe([0]);
});
});
// ========================================================================
// HistoryReadService: diagnostics
// ========================================================================
describe('HistoryReadService decode with diagnostics', function () {
it('decodes HistoryReadResponse with diagnostics', function () {
$session = new SessionService(1, 1);
$service = new HistoryReadService($session);
$encoder = new BinaryEncoder();
writeAdditionalMessagePrefix($encoder);
$encoder->writeNodeId(NodeId::numeric(0, 667));
writeAdditionalResponseHeader($encoder);
// Results: 0
$encoder->writeInt32(0);
// DiagnosticInfos: 1
$encoder->writeInt32(1);
writeFullDiagnosticInfo($encoder);
$decoder = new BinaryDecoder($encoder->getBuffer());
$result = $service->decodeHistoryReadResponse($decoder);
expect($result)->toBe([]);
});
it('decodes HistoryReadResponse where body has extra unconsumed bytes', function () {
$session = new SessionService(1, 1);
$service = new HistoryReadService($session);
$encoder = new BinaryEncoder();
writeAdditionalMessagePrefix($encoder);
$encoder->writeNodeId(NodeId::numeric(0, 667));
writeAdditionalResponseHeader($encoder);
// Results: 1
$encoder->writeInt32(1);
$encoder->writeUInt32(0); // StatusCode
$encoder->writeByteString(null); // ContinuationPoint
// HistoryData with body larger than consumed
$encoder->writeNodeId(NodeId::numeric(0, 658));
$encoder->writeByte(0x01);
$bodyEncoder = new BinaryEncoder();
$bodyEncoder->writeInt32(1);
$bodyEncoder->writeByte(0x01);
$bodyEncoder->writeByte(BuiltinType::Double->value);
$bodyEncoder->writeDouble(42.5);
$body = $bodyEncoder->getBuffer();
// Add extra bytes to body length
$paddedBody = $body . str_repeat("\x00", 12);
$encoder->writeInt32(strlen($paddedBody));
$encoder->writeRawBytes($paddedBody);
// DiagnosticInfos: 0
$encoder->writeInt32(0);
$decoder = new BinaryDecoder($encoder->getBuffer());
$result = $service->decodeHistoryReadResponse($decoder);
expect($result)->toHaveCount(1);
expect($result[0]->getValue())->toBe(42.5);
});
});
// ========================================================================
// SessionService: identity token types and decode paths
// ========================================================================
describe('SessionService activate with identity tokens', function () {
it('encodes ActivateSession with username/password (no security)', function () {
$session = new SessionService(1, 1);
$bytes = $session->encodeActivateSessionRequest(
1,
NodeId::numeric(0, 0),
'admin',
'secret',
);
$decoder = new BinaryDecoder($bytes);
$header = MessageHeader::decode($decoder);
expect($header->getMessageType())->toBe('MSG');
expect(strlen($bytes))->toBeGreaterThan(60);
});
it('encodes ActivateSession with X509 cert (no security)', function () {
$privKey = openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]);
$csr = openssl_csr_new(['CN' => 'test'], $privKey);
$cert = openssl_csr_sign($csr, null, $privKey, 365);
openssl_x509_export($cert, $certPem);
$cm = new CertificateManager();
$tmpFile = tempnam(sys_get_temp_dir(), 'opcua_test_');
file_put_contents($tmpFile, $certPem);
$userCertDer = $cm->loadCertificatePem($tmpFile);
unlink($tmpFile);
$session = new SessionService(1, 1);
$bytes = $session->encodeActivateSessionRequest(
1,
NodeId::numeric(0, 0),
null,
null,
$userCertDer,
$privKey,
'server-nonce',
);
expect(strlen($bytes))->toBeGreaterThan(60);
});
it('encodes ActivateSession with secure channel (anonymous)', function () {
$session = createSecureSession();
$bytes = $session->encodeActivateSessionRequest(
1,
NodeId::numeric(0, 0),
);
expect(substr($bytes, 0, 3))->toBe('MSG');
});
it('encodes ActivateSession with secure channel and username', function () {
$session = createSecureSession();
$bytes = $session->encodeActivateSessionRequest(
1,
NodeId::numeric(0, 0),
'admin',
'password123',
null,
null,
'server-nonce-bytes',
);
expect(substr($bytes, 0, 3))->toBe('MSG');
expect(strlen($bytes))->toBeGreaterThan(100);
});
it('encodes ActivateSession with secure channel and X509 cert', function () {
$session = createSecureSession();
$privKey = openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]);
$csr = openssl_csr_new(['CN' => 'user'], $privKey);
$cert = openssl_csr_sign($csr, null, $privKey, 365);
openssl_x509_export($cert, $certPem);
$cm = new CertificateManager();
$tmpFile = tempnam(sys_get_temp_dir(), 'opcua_user_');
file_put_contents($tmpFile, $certPem);
$userCertDer = $cm->loadCertificatePem($tmpFile);
unlink($tmpFile);
$bytes = $session->encodeActivateSessionRequest(
1,
NodeId::numeric(0, 0),
null,
null,
$userCertDer,
$privKey,
'server-nonce',
);
expect(substr($bytes, 0, 3))->toBe('MSG');
});
});
describe('SessionService decode additional paths', function () {
it('decodes CreateSessionResponse with server endpoints and software certs', function () {
$session = new SessionService(1, 1);
$encoder = new BinaryEncoder();
// Security + sequence header
$encoder->writeUInt32(1);
$encoder->writeUInt32(1);
$encoder->writeUInt32(1);
// TypeId: CreateSessionResponse (464)
$encoder->writeNodeId(NodeId::numeric(0, 464));
// ResponseHeader
writeAdditionalResponseHeader($encoder);
// CreateSession response fields
$encoder->writeNodeId(NodeId::numeric(0, 999)); // SessionId
$encoder->writeNodeId(NodeId::numeric(0, 888)); // AuthenticationToken
$encoder->writeDouble(120000.0); // RevisedSessionTimeout
$encoder->writeByteString('server-nonce-123'); // ServerNonce
$encoder->writeByteString('fake-server-cert'); // ServerCertificate
// ServerEndpoints: 1 endpoint
$encoder->writeInt32(1);
// Full EndpointDescription
$encoder->writeString('opc.tcp://localhost:4840'); // EndpointUrl
// ApplicationDescription
$encoder->writeString('urn:server'); // ApplicationUri
$encoder->writeString(null); // ProductUri
$encoder->writeByte(0x02);
$encoder->writeString('Server'); // ApplicationName (LocalizedText)
$encoder->writeUInt32(0); // ApplicationType
$encoder->writeString(null); // GatewayServerUri
$encoder->writeString(null); // DiscoveryProfileUri
$encoder->writeInt32(0); // DiscoveryUrls
$encoder->writeByteString(null); // ServerCertificate
$encoder->writeUInt32(1); // MessageSecurityMode: None
$encoder->writeString('http://opcfoundation.org/UA/SecurityPolicy#None'); // SecurityPolicyUri
// UserIdentityTokens: 1
$encoder->writeInt32(1);
$encoder->writeString('anonymous'); // PolicyId
$encoder->writeUInt32(0); // TokenType: Anonymous
$encoder->writeString(null); // IssuedTokenType
$encoder->writeString(null); // IssuerEndpointUrl
$encoder->writeString(null); // SecurityPolicyUri
$encoder->writeString(null); // TransportProfileUri
$encoder->writeByte(0); // SecurityLevel
// ServerSoftwareCertificates: 1
$encoder->writeInt32(1);
$encoder->writeByteString('cert-data'); // CertificateData
$encoder->writeByteString('sig-data'); // Signature
// ServerSignature
$encoder->writeString(null); // Algorithm
$encoder->writeByteString(null); // Signature
$encoder->writeUInt32(0); // MaxRequestMessageSize
$decoder = new BinaryDecoder($encoder->getBuffer());
$result = $session->decodeCreateSessionResponse($decoder);
expect($result['authenticationToken']->getIdentifier())->toBe(888);
expect($result['serverNonce'])->toBe('server-nonce-123');
expect($result['serverCertificate'])->toBe('fake-server-cert');
});
it('decodes ActivateSessionResponse with results and diagnostics', function () {
$session = new SessionService(1, 1);
$encoder = new BinaryEncoder();
$encoder->writeUInt32(1);
$encoder->writeUInt32(1);
$encoder->writeUInt32(1);
$encoder->writeNodeId(NodeId::numeric(0, 470)); // ActivateSessionResponse
writeAdditionalResponseHeader($encoder);
// ServerNonce
$encoder->writeByteString('new-nonce');
// Results: 2 StatusCodes
$encoder->writeInt32(2);
$encoder->writeUInt32(0);
$encoder->writeUInt32(0);
// DiagnosticInfos: 1 with nested inner diagnostic
$encoder->writeInt32(1);
$encoder->writeByte(0x21); // symbolicId + innerDiagnosticInfo
$encoder->writeInt32(42);
$encoder->writeByte(0x08); // inner: additionalInfo
$encoder->writeString('inner info');
$decoder = new BinaryDecoder($encoder->getBuffer());
// Should not throw
$session->decodeActivateSessionResponse($decoder);
expect(true)->toBeTrue();
});
});
// ========================================================================
// CertificateManager: loadCertificateDer
// ========================================================================
describe('CertificateManager loadCertificateDer', function () {
it('loads a DER certificate from file', function () {
// Generate a cert and save it as DER
$privKey = openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]);
$csr = openssl_csr_new(['CN' => 'test-der'], $privKey);
$cert = openssl_csr_sign($csr, null, $privKey, 365);
openssl_x509_export($cert, $certPem);
$cm = new CertificateManager();
// First load as PEM to get DER
$tmpPem = tempnam(sys_get_temp_dir(), 'opcua_pem_');
file_put_contents($tmpPem, $certPem);
$expectedDer = $cm->loadCertificatePem($tmpPem);
unlink($tmpPem);
// Now save as DER and load via loadCertificateDer
$tmpDer = tempnam(sys_get_temp_dir(), 'opcua_der_');
file_put_contents($tmpDer, $expectedDer);
try {
$loadedDer = $cm->loadCertificateDer($tmpDer);
expect($loadedDer)->toBe($expectedDer);
} finally {
unlink($tmpDer);
}
});
});
// ========================================================================
// SecureChannel: processMessage SignAndEncrypt round-trip
// ========================================================================
describe('SecureChannel processMessage SignAndEncrypt', function () {
it('round-trips buildMessage and processMessage in SignAndEncrypt mode', function () {
// Generate certs
$privKey = openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]);
$csr = openssl_csr_new(['CN' => 'test'], $privKey);
$cert = openssl_csr_sign($csr, null, $privKey, 365);
openssl_x509_export($cert, $certPem);
$cm = new CertificateManager();
$tmpFile = tempnam(sys_get_temp_dir(), 'opcua_rt_cert_');
file_put_contents($tmpFile, $certPem);
$certDer = $cm->loadCertificatePem($tmpFile);
unlink($tmpFile);
$policy = SecurityPolicy::Basic256Sha256;
$mode = SecurityMode::SignAndEncrypt;
// Create "client" channel
$clientChannel = new SecureChannel($policy, $mode, $certDer, $privKey, $certDer);
$clientChannel->createOpenSecureChannelMessage();
$clientNonce = $clientChannel->getClientNonce();
$serverNonce = random_bytes(32);
// Process OPN response to derive keys
$response = buildEncryptedOPNResponse(
$certDer,
$privKey,
$certDer,
$privKey,
$clientNonce,
$serverNonce,
100,
200,
$policy,
);
$clientChannel->processOpenSecureChannelResponse($response);
// Create a "server" channel with the SAME keys but swapped roles
// Client keys are used for sending, server keys for receiving
// The server would use the same nonces but swapped: deriveKeys(clientNonce, serverNonce) for its sending
// But for simplicity, we just verify the client's buildMessage produces valid output
// by checking the structure
$inner = new BinaryEncoder();
$inner->writeNodeId(NodeId::numeric(0, 631));
$inner->writeNodeId(NodeId::numeric(0, 0));
$inner->writeInt64(0);
$inner->writeUInt32(42);
$inner->writeUInt32(0);
$inner->writeString(null);
$inner->writeUInt32(10000);
$inner->writeNodeId(NodeId::numeric(0, 0));
$inner->writeByte(0);
$message = $clientChannel->buildMessage($inner->getBuffer());
// Verify the message structure
expect(substr($message, 0, 3))->toBe('MSG');
$decoder = new BinaryDecoder($message);
$header = MessageHeader::decode($decoder);
expect($header->getMessageSize())->toBe(strlen($message));
// The body should be encrypted (different from plaintext)
// Total size should be > the plaintext since it includes padding + signature + encryption overhead
expect(strlen($message))->toBeGreaterThan(strlen($inner->getBuffer()) + 50);
});
});
|