beremiz

Parents 735253f28b94
Children b2fb4580883a
WAMP: Support Client Certificate authentication (WAMPS-CRT://...) for IDE

URI scheme according to selected authentication:
WAMP:// unencrypted http, use generated PSK for CRA authentication
WAMP-ANNON:// unencrypted http, no authentication
WAMPS:// https with verified host name, use generated PSK for CRA authentication
WAMPS-ANNON:// https with verified host name, no authentication
WAMPS-INSECURE:// https with no verification, no authentication
WAMPS-NOVERIFY:// https with no verification, use generated PSK for CRA authentication
WAMPS-CRT:// https with verified host name, client certificate authentication

Tests updated accordingly.
--- a/CertManagement.py Thu Mar 20 15:02:11 2025 +0100
+++ b/CertManagement.py Fri Mar 21 11:10:59 2025 +0100
@@ -163,14 +163,14 @@
return True
-def _clientCertPath():
+def GetClientCert():
own_keystore = os.path.join(keystore_path, "own")
if not os.path.exists(own_keystore):
os.makedirs(own_keystore)
return os.path.join(own_keystore, "client.crt")
def GetClientCertificateInfo():
- file_path = _clientCertPath()
+ file_path = GetClientCert()
if os.path.exists(file_path):
info = ""
try:
@@ -196,10 +196,10 @@
return "No client certificate available"
def ImportClientCert(filepath):
- certpath = _clientCertPath()
+ certpath = GetClientCert()
shutil.copyfile(filepath, certpath)
def RemoveClientCert():
- certpath = _clientCertPath()
+ certpath = GetClientCert()
if os.path.exists(certpath ):
os.remove(certpath)
--- a/connectors/WAMP/__init__.py Thu Mar 20 15:02:11 2025 +0100
+++ b/connectors/WAMP/__init__.py Fri Mar 21 11:10:59 2025 +0100
@@ -31,7 +31,7 @@
from twisted.internet import reactor, threads
from twisted.internet._sslverify import OpenSSLCertificateAuthorities
-from twisted.internet.ssl import optionsForClientTLS, VerificationError
+from twisted.internet.ssl import PrivateCertificate, optionsForClientTLS, VerificationError
from autobahn.twisted import wamp
from autobahn.twisted.websocket import WampWebSocketClientFactory, connectWS
from autobahn.wamp import types, auth
@@ -50,10 +50,30 @@
_WampError = ""
+AUTH_NONE = "None"
+AUTH_PSK = "PSK"
+AUTH_CLIENTCERT = "ClientCertificate"
+AUTHENTICATION_TYPES = [AUTH_NONE, AUTH_PSK, AUTH_CLIENTCERT]
+SSL_AUTHENTICATION_TYPES = [AUTH_CLIENTCERT]
+
class WampSession(wamp.ApplicationSession):
def onConnect(self):
- user = self.config.extra["IDE_ID"]
- self.join(self.config.realm, ["wampcra"], user)
+ auth = self.config.extra["authentication"]
+ if auth == AUTH_NONE:
+ accepted_method = "anonymous"
+ authID = None
+ else:
+ authID = self.config.extra["IDE_ID"]
+ if auth == AUTH_PSK:
+ accepted_method = "wampcra"
+ elif auth in SSL_AUTHENTICATION_TYPES:
+ accepted_method = "tls"
+ else:
+ raise Exception("Invalid authentication: "+auth)
+
+ self.join(self.config.realm,
+ authmethods=[accepted_method],
+ authid=authID)
def onChallenge(self, challenge):
if challenge.method == "wampcra":
@@ -112,17 +132,28 @@
"""
WAMP://127.0.0.1:12345/path#realm#PLC_ID
WAMPS://127.0.0.1:12345/path#realm#PLC_ID
+ WAMP-CRT://127.0.0.1:12345/path#realm#PLC_ID
"""
scheme, location = uri.split("://")
urlpath, realm, PLC_ID = location.split('#')
- urlprefix = {"WAMP": "ws",
- "WAMPS": "wss"}[scheme]
+ urlprefix , ssl_auth, use_secret, use_ssl, verify, auth = {
+ "WAMP": ("ws" , 0 , 1 , 0 , 0 , AUTH_PSK ),
+ "WAMP-ANNON": ("ws" , 0 , 0 , 0 , 0 , AUTH_NONE ),
+ "WAMPS": ("wss", 0 , 1 , 1 , 1 , AUTH_PSK ),
+ "WAMPS-ANNON": ("wss", 0 , 0 , 1 , 1 , AUTH_NONE ),
+ "WAMPS-INSECURE":("wss", 0 , 0 , 1 , 0 , AUTH_NONE ),
+ "WAMPS-NOVERIFY":("wss", 0 , 1 , 1 , 0 , AUTH_PSK ),
+ "WAMPS-CRT": ("wss", 1 , 0 , 1 , 1 , AUTH_CLIENTCERT ),
+ }[scheme]
url = urlprefix+"://"+urlpath
CN = urlpath.split("/")[0].split(":")[0]
try:
+ IDE_ID, secret = PSK.GetIDEIdentity()
+ if use_ssl:
+ trust_store = Cert.GetCertPath(CN)
+ if ssl_auth:
+ client_cert_file = Cert.GetClientCert()
- IDE_ID, secret = PSK.GetIDEIdentity()
- trust_store = Cert.GetCertPath(CN)
except Exception as e:
confnodesroot.logger.write_error(
_("Connection to {loc} failed with exception {ex}\n").format(
@@ -134,15 +165,17 @@
# start logging to console
# log.startLogging(sys.stdout)
+ extraconf={
+ "IDE_ID": IDE_ID,
+ "authentication": auth
+ }
+ if use_secret:
+ extraconf["secret"] = secret
# create a WAMP application session factory
- component_config = types.ComponentConfig(
- realm=str(realm),
- extra={
- "IDE_ID": IDE_ID,
- "secret": secret
- })
session_factory = wamp.ApplicationSessionFactory(
- config=component_config)
+ config=types.ComponentConfig(
+ realm=str(realm),
+ extra=extraconf))
session_factory.session = cls
# create a WAMP-over-WebSocket transport client factory
@@ -153,17 +186,25 @@
contextFactory=None
if transport_factory.isSecure:
+
+ client_cert=None
trustRoot=None
- if trust_store:
+ if ssl_auth:
+ if os.path.exists(client_cert_file):
+ client_cert = PrivateCertificate.loadPEM(open(client_cert_file, 'rb').read())
+ else:
+ confnodesroot.logger.write_error(
+ _("WAMP client certificate not provided for: {loc}\n").format(loc=uri))
+ return
+ if verify:
if os.path.exists(trust_store):
- cert = crypto.load_certificate(
- crypto.FILETYPE_PEM,
- open(trust_store, 'rb').read()
- )
- trustRoot=OpenSSLCertificateAuthorities([cert])
- else:
- confnodesroot.logger.write_warning("Wamp trust store not found")
- contextFactory = optionsForClientTLS(transport_factory.host, trustRoot=trustRoot)
+ cert = crypto.load_certificate(crypto.FILETYPE_PEM,
+ open(trust_store, 'rb').read())
+ trustRoot = OpenSSLCertificateAuthorities([cert])
+ if verify or ssl_auth:
+ contextFactory=optionsForClientTLS(transport_factory.host,
+ trustRoot=trustRoot,
+ clientCertificate=client_cert)
# start the client from a Twisted endpoint
conn = connectWS(transport_factory, contextFactory)
--- a/connectors/__init__.py Thu Mar 20 15:02:11 2025 +0100
+++ b/connectors/__init__.py Fri Mar 21 11:10:59 2025 +0100
@@ -79,12 +79,13 @@
runtime_port = confnodesroot.StartLocalRuntime()
uri = f"ERPC://{LocalHost}:{runtime_port}"
- elif _scheme in connectors:
+ else:
+ _scheme = _scheme.split("-")[0]
+
+ if _scheme in connectors:
scheme = _scheme
elif _scheme[-1] == 'S' and _scheme[:-1] in connectors:
scheme = _scheme[:-1]
- elif _scheme.split("-")[0] in connectors:
- scheme = _scheme.split("-")[0]
else:
return None
--- a/runtime/WampClient.py Thu Mar 20 15:02:11 2025 +0100
+++ b/runtime/WampClient.py Fri Mar 21 11:10:59 2025 +0100
@@ -123,22 +123,22 @@
class WampSession(wamp.ApplicationSession):
def onConnect(self):
- user = self.config.extra["ID"]
auth = self.config.extra["authentication"]
- if auth == AUTH_PSK:
- self.join(self.config.realm, ["wampcra"], user)
- elif auth == AUTH_NONE:
- self.join(self.config.realm, ["anonymous"])
- elif auth in SSL_AUTHENTICATION_TYPES:
- authextra = {
- 'channel_binding': "None" # "tls-unique"
- }
- self.join(self.config.realm,
- authmethods=['tls'],
- authid=user,
- authextra=authextra)
+ if auth == AUTH_NONE:
+ accepted_method = "anonymous"
+ authID = None
else:
- raise Exception("Invalid authentication: "+auth)
+ authID = self.config.extra["ID"]
+ if auth == AUTH_PSK:
+ accepted_method = "wampcra"
+ elif auth in SSL_AUTHENTICATION_TYPES:
+ accepted_method = "tls"
+ else:
+ raise Exception("Invalid authentication: "+auth)
+
+ self.join(self.config.realm,
+ authmethods=[accepted_method],
+ authid=authID)
def onChallenge(self, challenge):
if challenge.method == "wampcra":
--- a/tests/cli_tests/wamp_test_PSK_and_TLS.bash Thu Mar 20 15:02:11 2025 +0100
+++ b/tests/cli_tests/wamp_test_PSK_and_TLS.bash Fri Mar 21 11:10:59 2025 +0100
@@ -5,6 +5,9 @@
APPDATA=$HOME/.local/share/beremiz
KEYSTORE=$APPDATA/keystore
+# Set BEREMIZ_LOCAL_HOST to localhost if not already set
+:${BEREMIZ_LOCAL_HOST:=localhost}
+
# Start runtime one first time to generate PLC PSK
$BEREMIZPYTHONPATH $BEREMIZPATH/Beremiz_service.py -s psk.txt -n test_wamp_ID -x 0 &
PLC_PID=$!
@@ -30,9 +33,10 @@
IFS=':' read -r PLC_wamp_ID PLC_wamp_secret < psk.txt
+URI="WAMPS://${BEREMIZ_LOCAL_HOST}:8888/ws#Automation#${PLC_wamp_ID}"
+
# Prepare test project
cp -a $BEREMIZPATH/tests/projects/wamp .
-URI="WAMPS://localhost:8888/ws#Automation#${PLC_wamp_ID}"
sed -i "s,TEST_URI,${URI},g" wamp/beremiz.xml
# Start CLI one first time to generate IDE PSK
@@ -67,7 +71,7 @@
mkdir -p .crossbar
yes "" | openssl req -nodes -new -x509 -keyout ./.crossbar/server.key \
- -addext "subjectAltName = DNS:localhost" \
+ -addext "subjectAltName = DNS:${BEREMIZ_LOCAL_HOST}" \
-out ./.crossbar/server.crt
cat > .crossbar/config.json <<JsonEnd
@@ -177,7 +181,7 @@
},
"realm": "Automation",
"authentication": "PSK",
- "url": "wss://localhost:8888/ws"
+ "url": "wss://${BEREMIZ_LOCAL_HOST}:8888/ws"
}
JsonEnd
@@ -225,10 +229,7 @@
# Re-use self-signed server cert for client in test project
IDE_CERT=$KEYSTORE/cert
mkdir -p $IDE_CERT
-cp .crossbar/server.crt $IDE_CERT/localhost.crt
-
-# TODO: patch project's URI to connect to $BEREMIZ_LOCAL_HOST
-# used in tests instead of 127.0.0.1
+cp .crossbar/server.crt $IDE_CERT/${BEREMIZ_LOCAL_HOST}.crt
# Use CLI to build transfer and start PLC
$BEREMIZPYTHONPATH $BEREMIZPATH/Beremiz_cli.py -k \
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/tests/cli_tests/wamp_test_client_cert.bash Fri Mar 21 11:10:59 2025 +0100
@@ -0,0 +1,329 @@
+#!/bin/bash
+
+rm -f ./CLI_OK ./PLC_OK ./PLC_CONNECTED
+
+APPDATA=$HOME/.local/share/beremiz
+KEYSTORE=$APPDATA/keystore
+
+PLC_wamp_ID="PLC_1234"
+IDE_wamp_ID="IDE_1234"
+
+# Set BEREMIZ_LOCAL_HOST to localhost if not already set
+:${BEREMIZ_LOCAL_HOST:=localhost}
+
+URI="WAMPS-CRT://${BEREMIZ_LOCAL_HOST}:8888/ws#Automation#${PLC_wamp_ID}"
+
+# Array of client IDs
+client_cns=(${IDE_wamp_ID} ${PLC_wamp_ID})
+
+# Create base directory for the certificates and keys
+mkdir -p certs/ca certs/server certs/clients
+
+yes "" | openssl req -nodes -new -x509 -keyout certs/server/server.key \
+ -addext "subjectAltName = DNS:${BEREMIZ_LOCAL_HOST}" \
+ -out certs/server/server.crt
+
+# Declare an associative array to store client certificate SHA1 fingerprints
+declare -A client_fingerprints
+
+# Loop through each client CN and generate keys and certificates
+for cn in "${client_cns[@]}"; do
+
+ # Generate client cert to be signed
+ openssl req -nodes -newkey rsa:2048 -keyout certs/clients/${cn}.key -out certs/clients/${cn}.csr -subj "/C=AU/ST=NSW/L=Sydney/O=Beremiz/OU=client/CN=${cn}"
+
+ # Sign the client cert
+ openssl x509 -req -in certs/clients/${cn}.csr -CA certs/server/server.crt -CAkey certs/server/server.key -out certs/clients/${cn}.crt
+
+ # Get the SHA1 fingerprint of the client certificate
+ fingerprint=$(openssl x509 -in certs/clients/${cn}.crt -noout -fingerprint -sha1 | sed 's/.*=//')
+ client_fingerprints["${cn}"]="${fingerprint}"
+
+ # Create a PEM file containing the client certificate and private key
+ cat "certs/clients/${cn}.crt" "certs/clients/${cn}.key" > "certs/clients/${cn}.pem"
+
+done
+
+# Prepare crossbar server configuration
+mkdir -p .crossbar
+cp certs/server/server.crt ./.crossbar/ca.crt # In our test server is CA
+cp certs/server/server.key ./.crossbar/server.key
+cp certs/server/server.crt ./.crossbar/server.crt
+
+# Crossbar need a Python Authenticator component to decide if Client Cert is OK
+cat > authenticator.py <<PythonEnd
+from twisted.internet.defer import inlineCallbacks
+
+from autobahn.twisted.wamp import ApplicationSession
+from autobahn.wamp.exception import ApplicationError
+
+
+class AutomationAuthenticator(ApplicationSession):
+
+ # our "database" of accepted client certificate fingerprints
+ ACCEPTED_CERTS = {
+PythonEnd
+
+for client in "${!client_fingerprints[@]}"; do
+ echo " '${client_fingerprints[$client]}':'${client}'," >> authenticator.py
+done
+
+cat >> authenticator.py <<PythonEnd
+ }
+ @inlineCallbacks
+ def onJoin(self, details):
+
+ def authenticate(realm, authid, details):
+ client_cert = details['transport'].get('peer_cert', None)
+
+ if not client_cert:
+ raise ApplicationError("automation.no_cert", "no client certificate presented")
+
+ sha1 = client_cert['sha1']
+
+ subject_cn = client_cert['subject']['cn']
+
+ if sha1 not in self.ACCEPTED_CERTS:
+ print("AutomationAuthenticator.authenticate: client denied.")
+ raise ApplicationError("automation.invalid_cert", "certificate with SHA1 {} denied".format(sha1))
+ else:
+ print("AutomationAuthenticator.authenticate: client accepted.")
+ return {
+ 'authid': subject_cn,
+ 'role': 'authenticated'
+ }
+
+ try:
+ yield self.register(authenticate, 'automation.authenticate')
+ print("AutomationAuthenticator: dynamic authenticator registered.")
+ except Exception as e:
+ print("AutomationAuthenticator: could not register dynamic authenticator - {}".format(e))
+PythonEnd
+
+# Crossbar configuration that uses Python Authenticator component
+cat > .crossbar/config.json <<JsonEnd
+{
+ "version": 2,
+ "workers": [
+ {
+ "type": "router",
+ "id": "automation_router",
+ "realms": [
+ {
+ "name": "Automation",
+ "roles": [
+ {
+ "name": "authenticated",
+ "permissions": [
+ {
+ "uri": "*",
+ "allow": {
+ "call": true,
+ "register": true,
+ "publish": true,
+ "subscribe": true
+ },
+ "disclose": {
+ "caller": false,
+ "publisher": false
+ },
+ "cache": true
+ }
+ ]
+ },
+ {
+ "name": "authenticator",
+ "permissions": [
+ {
+ "uri": "automation.authenticate",
+ "match": "exact",
+ "allow": {
+ "call": true,
+ "register": true,
+ "publish": true,
+ "subscribe": true
+ },
+ "disclose": {
+ "caller": false,
+ "publisher": false
+ },
+ "cache": true
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "transports": [
+ {
+ "type": "web",
+ "endpoint": {
+ "type": "tcp",
+ "port": 8888,
+ "tls": {
+ "certificate": "server.crt",
+ "key": "server.key",
+ "ca_certificates": [
+ "ca.crt"
+ ]
+ }
+ },
+ "paths": {
+ "ws": {
+ "type": "websocket",
+ "auth": {
+ "tls": {
+ "type": "dynamic",
+ "authenticator": "automation.authenticate"
+ }
+ }
+ }
+ }
+ }
+ ],
+ "components": [
+ {
+ "type": "class",
+ "classname": "authenticator.AutomationAuthenticator",
+ "realm": "Automation",
+ "role": "authenticator"
+ }
+ ]
+ }
+ ]
+}
+JsonEnd
+crossbar start &> crossbar_log.txt &
+SERVER_PID=$!
+res=110 # default to ETIMEDOUT
+c=15
+while ((c--)); do
+ if [[ -a .crossbar/node.pid ]]; then
+ echo found crossbar pid
+ res=0 # OK success
+ break
+ else
+ echo wait for crossbar to start.... $c
+ sleep 1
+ fi
+done
+
+if [ "$res" != "0" ] ; then
+ kill $SERVER_PID
+ echo timeout starting crossbar.
+ exit $res
+fi
+
+# give more time to crossbar
+sleep 3
+
+# Prepare runtime Wamp config
+cat > wampconf.json <<JsonEnd
+{
+ "ID": "${PLC_wamp_ID}",
+ "active": true,
+ "protocolOptions": {
+ "autoPingInterval": 60,
+ "autoPingTimeout": 20
+ },
+ "realm": "Automation",
+ "authentication": "ClientCertificate",
+ "verifyHostname": true,
+ "url": "wss://${BEREMIZ_LOCAL_HOST}:8888/ws"
+}
+JsonEnd
+
+# Re-use self-signed server cert for client
+cp .crossbar/server.crt wampTrustStore.crt
+cp certs/clients/${PLC_wamp_ID}.pem wampClientCert.pem
+
+# Start Beremiz runtime again, with wamp enabled
+$BEREMIZPYTHONPATH $BEREMIZPATH/Beremiz_service.py -c wampconf.json -s psk.txt -n test_wamp_ID -x 0 &> >(
+ echo "Start PLC loop"
+ while read line; do
+ # Wait for server to print modified value
+ echo "PLC>> $line"
+ if [[ "$line" =~ "WAMP session joined" ]]; then
+ echo "PLC is connected"
+ touch ./PLC_CONNECTED
+ fi
+ if [[ "$line" == "PLCobject : PLC started" ]]; then
+ echo "PLC was programmed"
+ touch ./PLC_OK
+ fi
+ done
+ echo "End PLC loop"
+) &
+PLC_PID=$!
+
+echo wait for runtime to come up
+res=110 # default to ETIMEDOUT
+c=30
+while ((c--)); do
+ if [[ -a ./PLC_CONNECTED ]]; then
+ res=0 # OK success
+ break
+ else
+ sleep 1
+ fi
+done
+
+if [ "$res" != "0" ] ; then
+ kill $SERVER_PID
+ kill $PLC_PID
+ echo timeout connecting PLC to crossbar.
+ exit $res
+fi
+
+
+# Prepare test project
+cp -a $BEREMIZPATH/tests/projects/wamp .
+sed -i "s,TEST_URI,${URI},g" wamp/beremiz.xml
+
+
+# Re-use self-signed server cert for client in test project
+IDE_CERT=$KEYSTORE/cert
+mkdir -p $IDE_CERT
+cp .crossbar/server.crt $IDE_CERT/${BEREMIZ_LOCAL_HOST}.crt
+
+IDE_CLIENT_CERT=$KEYSTORE/own/client.crt
+rm -f $IDE_CLIENT_CERT
+cp certs/clients/${IDE_wamp_ID}.pem $IDE_CLIENT_CERT
+
+# Use CLI to build transfer and start PLC
+$BEREMIZPYTHONPATH $BEREMIZPATH/Beremiz_cli.py -k \
+ --project-home wamp build transfer run &> >(
+echo "Start CLI loop"
+while read line; do
+ # Wait for PLC runtime to output expected value on stdout
+ echo "CLI>> $line"
+ if [[ "$line" == "PLC installed successfully." ]]; then
+ echo "CLI did transfer PLC program"
+ touch ./CLI_OK
+ fi
+done
+echo "End CLI loop"
+) &
+CLI_PID=$!
+
+echo all subprocess started, start polling results
+res=110 # default to ETIMEDOUT
+c=30
+while ((c--)); do
+ if [[ -a ./CLI_OK && -a ./PLC_OK ]]; then
+ echo got results.
+ res=0 # OK success
+ break
+ else
+ echo waiting.... $c
+ sleep 1
+ fi
+done
+
+# Kill PLC and subprocess
+echo will kill PLC:$PLC_PID, SERVER:$SERVER_PID and CLI:$CLI_PID
+kill $PLC_PID
+kill $CLI_PID
+kill $SERVER_PID
+
+exit $res