diff --git a/.github/workflows/build-knx-addon.yml b/.github/workflows/build-knx-addon.yml new file mode 100644 index 0000000000000..aaf6d74a43c5a --- /dev/null +++ b/.github/workflows/build-knx-addon.yml @@ -0,0 +1,44 @@ +# This workflow will build a Java project with Maven +# For more information see: https://help.github.com/actions/language-and-framework-guides/building-and-testing-java-with-maven + +name: Build knx addon with Maven + +on: + push: + branches: [ pr-knx-data-secure ] + +jobs: + build320: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Set up Java 11 + uses: actions/setup-java@v1 + with: + java-version: 11 + - name: Build with Maven + run: mvn -B package --file pom.xml -pl :org.openhab.binding.knx -Dohc.version=3.2.0 + - uses: actions/upload-artifact@v2 + with: + name: org.openhab.binding.knx.320 + path: bundles/org.openhab.binding.knx/target/org.openhab.binding.knx-*.jar + + build31x: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Set up Java 11 + uses: actions/setup-java@v1 + with: + java-version: 11 + - name: Build with Maven + run: mvn -B package --file pom.xml -pl :org.openhab.binding.knx + - uses: actions/upload-artifact@v2 + with: + name: org.openhab.binding.knx.33x + path: bundles/org.openhab.binding.knx/target/org.openhab.binding.knx-*.jar + diff --git a/bundles/org.openhab.binding.knx/README.md b/bundles/org.openhab.binding.knx/README.md index 5339c28629775..14891f96d0918 100644 --- a/bundles/org.openhab.binding.knx/README.md +++ b/bundles/org.openhab.binding.knx/README.md @@ -29,7 +29,7 @@ The IP Gateway is the most commonly used way to connect to the KNX bus. At its b | Name | Required | Description | Default value | |---------------------|--------------|--------------------------------------------------------------------------------------------------------------|------------------------------------------------------| -| type | Yes | The IP connection type for connecting to the KNX bus (`TUNNEL` or `ROUTER`) | - | +| type | Yes | The IP connection type for connecting to the KNX bus (`TUNNEL`, `ROUTER`, `SECURETUNNEL` or `SECUREROUTER`) | - | | ipAddress | for `TUNNEL` | Network address of the KNX/IP gateway. If type `ROUTER` is set, the IPv4 Multicast Address can be set. | for `TUNNEL`: \, for `ROUTER`: 224.0.23.12 | | portNumber | for `TUNNEL` | Port number of the KNX/IP gateway | 3671 | | localIp | No | Network address of the local host to be used to set up the connection to the KNX/IP gateway | the system-wide configured primary interface address | @@ -52,6 +52,7 @@ The *serial* bridge accepts the following configuration parameters: | responseTimeout | N | Timeout in seconds to wait for a response from the KNX bus | 10 | | readRetriesLimit | N | Limits the read retries while initialization from the KNX bus | 3 | | autoReconnectPeriod | N | Seconds between connect retries when KNX link has been lost, 0 means never retry | 0 | +| useCemi | N | Use newer CEMI message format, could be useful for newer devices like KNX RF sticks, kBerry, etc. | false | ## Things @@ -199,6 +200,36 @@ Each configuration parameter has a `mainGA` where commands are written to and op The `dpt` element is optional. If ommitted, the corresponding default value will be used (see the channel descriptions above). +## KNX Secure + +> Note: Support for KNX Secure is partly implemented for openHAB and should be considered as experimental. + +### KNX IP Secure + +KNX IP Secure protects the traffic between openHAB and your KNX installation. It requires either a KNX Secure Router or a Secure IP Interface with security features enabled in ETS tool. + +For *Secure routing* mode, the so called `backbone key` needs to be configured in openHAB. It is created by the ETS tool and cannot be changed via the ETS user interface. There are two possible ways to provide the key to openHAB: + +- The backbone key can be extracted Security report (ETS, Reports, Security, look for a 32-digit key) and specified in parameter `backboneKey`. +- The backbone key is included in ETS keyring export (ETS, project settings, export keyring). Keyring file is configured using `keyringFile` (put it in `config\misc` folder of the openHAB installation) and also requires `keyringPasswort`. + +For *Secure tunneling* with a Secure IP Interface (or a router in tunneling mode), more parameters are required. A unique device authentication key, and a specific tunnel identifier and password need to be available. It can be provided to openHAB in two different ways: + +- All information can be looked up in ETS and provided separately: `tunnelDevAuth`, `tunnelPasswort`. `tunnelUser` is a number which is not directly visible in ETS, but can be looked up in keyring export or deduced (typically 2 for the first tunnel of a device, 3 for the second one, ...) +- All necessary information is included in ETS keyring export (ETS, project settings, export keyring). Keyring file is configured using `keyringFile` (put it in `config\misc` folder of the openHAB installation) and `keyringPasswort`. In addition, `tunnelSourceAddr` needs to be set to uniquely identify the tunnel in use. + + +### KNX Data Secure + +Data secure protects the content of messages on the KNX bus. In a KNX installation, both classic and secure group addresses can coexist. + +openHAB typically ignores messages with secure group addresses, unless data secure is configured. + +> NOTE: openHAB currently does fully support passive (listening) access to secure group addresses. Write access to secured GAs is done via the "GO diagnostics" feature described in KNX AN170 and is currently limited. Expect a timeout if a data value is written too often. Initial/periodic read will fail, avoid automatic read (< in thing definition). + +All necessary information to decode secure group addresses is included in ETS keyring export (ETS, project settings, export keyring). Keyring file is configured using `keyringFile` (put it in `config\misc` folder of the openHAB installation) and also requires `keyringPasswort`. + + ## Examples The following two templates are sufficient for almost all purposes. diff --git a/bundles/org.openhab.binding.knx/src/main/java/org/openhab/binding/knx/internal/KNXBindingConstants.java b/bundles/org.openhab.binding.knx/src/main/java/org/openhab/binding/knx/internal/KNXBindingConstants.java index 4f243cb62c723..cee51dd2ed521 100644 --- a/bundles/org.openhab.binding.knx/src/main/java/org/openhab/binding/knx/internal/KNXBindingConstants.java +++ b/bundles/org.openhab.binding.knx/src/main/java/org/openhab/binding/knx/internal/KNXBindingConstants.java @@ -51,6 +51,14 @@ public class KNXBindingConstants { public static final String LOCAL_SOURCE_ADDRESS = "localSourceAddr"; public static final String PORT_NUMBER = "portNumber"; public static final String SERIAL_PORT = "serialPort"; + public static final String USE_CEMI = "useCemi"; + public static final String KEYRING_FILE = "keyringFile"; + public static final String KEYRING_PASSWORD = "keyringPassword"; + public static final String ROUTER_BACKBONE_GROUP_KEY = "routerBackboneGroupKey"; + public static final String TUNNEL_USER_ID = "tunnelUserId"; + public static final String TUNNEL_USER_PASSWORD = "tunnelUserPassword"; + public static final String TUNNEL_DEVICE_AUTHENTICATION = "tunnelDeviceAuthentication"; + public static final String TUNNEL_SOURCE_ADDRESS = "tunnelSourceAddress"; // The default multicast ip address (see iana EIBnet/IP diff --git a/bundles/org.openhab.binding.knx/src/main/java/org/openhab/binding/knx/internal/KNXTypeMapper.java b/bundles/org.openhab.binding.knx/src/main/java/org/openhab/binding/knx/internal/KNXTypeMapper.java index 2fb88e342b49c..69d36652b9e21 100644 --- a/bundles/org.openhab.binding.knx/src/main/java/org/openhab/binding/knx/internal/KNXTypeMapper.java +++ b/bundles/org.openhab.binding.knx/src/main/java/org/openhab/binding/knx/internal/KNXTypeMapper.java @@ -17,7 +17,6 @@ import org.openhab.core.types.Type; import tuwien.auto.calimero.datapoint.Datapoint; -import tuwien.auto.calimero.process.ProcessEvent; /** * This interface must be implemented by classes that provide a type mapping @@ -45,7 +44,8 @@ public interface KNXTypeMapper { * maps a datapoint value to an openHAB command or state * * @param datapoint the source datapoint - * @param data the datapoint value as an ASDU byte array (see {@link ProcessEvent}.getASDU()) + * @param data the datapoint value as an ASDU byte array (see + * {@link tuwien.auto.calimero.process.ProcessEvent}.getASDU()) * @return a command or state of openHAB */ @Nullable diff --git a/bundles/org.openhab.binding.knx/src/main/java/org/openhab/binding/knx/internal/channel/AbstractSpec.java b/bundles/org.openhab.binding.knx/src/main/java/org/openhab/binding/knx/internal/channel/AbstractSpec.java index bbe5e30f18ddd..717a41626f311 100644 --- a/bundles/org.openhab.binding.knx/src/main/java/org/openhab/binding/knx/internal/channel/AbstractSpec.java +++ b/bundles/org.openhab.binding.knx/src/main/java/org/openhab/binding/knx/internal/channel/AbstractSpec.java @@ -14,8 +14,6 @@ import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; -import org.openhab.binding.knx.internal.client.InboundSpec; -import org.openhab.binding.knx.internal.client.OutboundSpec; import tuwien.auto.calimero.GroupAddress; import tuwien.auto.calimero.KNXFormatException; @@ -57,7 +55,8 @@ protected final GroupAddress toGroupAddress(GroupAddressConfiguration ga) { /** * Return the data point type. *

- * See {@link InboundSpec#getDPT()} and {@link OutboundSpec#getDPT()}. + * See {@link org.openhab.binding.knx.internal.client.InboundSpec#getDPT()} and + * {@link org.openhab.binding.knx.internal.client.OutboundSpec#getDPT()}. * * @return the data point type. */ diff --git a/bundles/org.openhab.binding.knx/src/main/java/org/openhab/binding/knx/internal/client/AbstractKNXClient.java b/bundles/org.openhab.binding.knx/src/main/java/org/openhab/binding/knx/internal/client/AbstractKNXClient.java index 695edb277edfe..b6a5da1a6293b 100644 --- a/bundles/org.openhab.binding.knx/src/main/java/org/openhab/binding/knx/internal/client/AbstractKNXClient.java +++ b/bundles/org.openhab.binding.knx/src/main/java/org/openhab/binding/knx/internal/client/AbstractKNXClient.java @@ -54,6 +54,7 @@ import tuwien.auto.calimero.process.ProcessCommunicatorImpl; import tuwien.auto.calimero.process.ProcessEvent; import tuwien.auto.calimero.process.ProcessListener; +import tuwien.auto.calimero.secure.KnxSecureException; import tuwien.auto.calimero.secure.SecureApplicationLayer; import tuwien.auto.calimero.secure.Security; @@ -91,6 +92,9 @@ public abstract class AbstractKNXClient implements NetworkLinkListener, KNXClien private final Set groupAddressListeners = new CopyOnWriteArraySet<>(); private final LinkedBlockingQueue readDatapoints = new LinkedBlockingQueue<>(); + private boolean firstConnect; + private long lastDisconnectSysMillis; + @FunctionalInterface private interface ListenerNotification { void apply(BusMessageListener listener, IndividualAddress source, GroupAddress destination, byte[] asdu); @@ -101,6 +105,7 @@ private interface ListenerNotification { @Override public void detached(DetachEvent e) { + lastDisconnectSysMillis = System.currentTimeMillis(); logger.debug("The KNX network link was detached from the process communicator"); } @@ -135,6 +140,8 @@ public AbstractKNXClient(int autoReconnectPeriod, ThingUID thingUID, int respons this.readRetriesLimit = readRetriesLimit; this.knxScheduler = knxScheduler; this.statusUpdateCallback = statusUpdateCallback; + firstConnect = true; + lastDisconnectSysMillis = System.currentTimeMillis(); } public void initialize() { @@ -145,7 +152,9 @@ public void initialize() { private boolean scheduleReconnectJob() { if (autoReconnectPeriod > 0) { - connectJob = knxScheduler.schedule(this::connect, autoReconnectPeriod, TimeUnit.SECONDS); + // schedule connect job, for the first connection ignore autoReconnectPeriod and use 1 sec + connectJob = knxScheduler.schedule(this::connect, firstConnect ? 1 : autoReconnectPeriod, TimeUnit.SECONDS); + firstConnect = false; return true; } else { return false; @@ -173,6 +182,10 @@ private synchronized boolean connect() { if (isConnected()) { return true; } + long now = System.currentTimeMillis(); + if ((now - lastDisconnectSysMillis) < 1000) { + logger.debug("fast reconnect"); + } try { releaseConnection(); @@ -206,7 +219,7 @@ private synchronized boolean connect() { statusUpdateCallback.updateStatus(ThingStatus.ONLINE); connectJob = null; return true; - } catch (KNXException | InterruptedException e) { + } catch (KNXException | InterruptedException | KnxSecureException e) { logger.debug("Error connecting to the bus: {}", e.getMessage(), e); disconnect(e); scheduleReconnectJob(); @@ -215,11 +228,12 @@ private synchronized boolean connect() { } private void disconnect(@Nullable Exception e) { + lastDisconnectSysMillis = System.currentTimeMillis(); + releaseConnection(); if (e != null) { - String message = e.getLocalizedMessage(); statusUpdateCallback.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, - message != null ? message : ""); + "" + e.getLocalizedMessage()); } else { statusUpdateCallback.updateStatus(ThingStatus.OFFLINE); } diff --git a/bundles/org.openhab.binding.knx/src/main/java/org/openhab/binding/knx/internal/client/CustomKNXNetworkLinkIP.java b/bundles/org.openhab.binding.knx/src/main/java/org/openhab/binding/knx/internal/client/CustomKNXNetworkLinkIP.java index f9359b918305f..227c64ac3c252 100644 --- a/bundles/org.openhab.binding.knx/src/main/java/org/openhab/binding/knx/internal/client/CustomKNXNetworkLinkIP.java +++ b/bundles/org.openhab.binding.knx/src/main/java/org/openhab/binding/knx/internal/client/CustomKNXNetworkLinkIP.java @@ -27,6 +27,7 @@ public class CustomKNXNetworkLinkIP extends KNXNetworkLinkIP { public static final int TUNNELING = KNXNetworkLinkIP.TUNNELING; + public static final int TUNNELINGV2 = KNXNetworkLinkIP.TunnelingV2; public static final int ROUTING = KNXNetworkLinkIP.ROUTING; CustomKNXNetworkLinkIP(final int serviceMode, KNXnetIPConnection conn, KNXMediumSettings settings) diff --git a/bundles/org.openhab.binding.knx/src/main/java/org/openhab/binding/knx/internal/client/IPClient.java b/bundles/org.openhab.binding.knx/src/main/java/org/openhab/binding/knx/internal/client/IPClient.java index 045fefadb63fe..8757a8af3cedc 100644 --- a/bundles/org.openhab.binding.knx/src/main/java/org/openhab/binding/knx/internal/client/IPClient.java +++ b/bundles/org.openhab.binding.knx/src/main/java/org/openhab/binding/knx/internal/client/IPClient.java @@ -17,6 +17,7 @@ import java.net.NetworkInterface; import java.net.SocketException; import java.net.UnknownHostException; +import java.time.Duration; import java.util.concurrent.ScheduledExecutorService; import org.eclipse.jdt.annotation.NonNullByDefault; @@ -32,6 +33,8 @@ import tuwien.auto.calimero.knxnetip.KNXnetIPRouting; import tuwien.auto.calimero.knxnetip.KNXnetIPTunnel; import tuwien.auto.calimero.knxnetip.KNXnetIPTunnel.TunnelingLayer; +import tuwien.auto.calimero.knxnetip.SecureConnection; +import tuwien.auto.calimero.knxnetip.TcpConnection; import tuwien.auto.calimero.link.KNXNetworkLink; import tuwien.auto.calimero.link.KNXNetworkLinkIP; import tuwien.auto.calimero.link.medium.KNXMediumSettings; @@ -46,23 +49,38 @@ @NonNullByDefault public class IPClient extends AbstractKNXClient { + public enum IpConnectionType { + TUNNEL, + ROUTER, + SECURE_TUNNEL, + SECURE_ROUTER + }; + private final Logger logger = LoggerFactory.getLogger(IPClient.class); private static final String MODE_ROUTER = "ROUTER"; private static final String MODE_TUNNEL = "TUNNEL"; + private static final String MODE_SECURE_ROUTER = "SECURE ROUTER"; + private static final String MODE_SECURE_TUNNEL = "SECURE TUNNEL"; - private final int ipConnectionType; + private final IpConnectionType ipConnectionType; private final String ip; private final String localSource; private final int port; @Nullable private final InetSocketAddress localEndPoint; private final boolean useNAT; + private final byte[] secureRoutingBackboneGroupKey; + private final long secureRoutingLatencyToleranceMs; + private final byte[] secureTunnelDevKey; + private final int secureTunnelUser; + private final byte[] secureTunnelUserKey; - public IPClient(int ipConnectionType, String ip, String localSource, int port, - @Nullable InetSocketAddress localEndPoint, boolean useNAT, int autoReconnectPeriod, ThingUID thingUID, - int responseTimeout, int readingPause, int readRetriesLimit, ScheduledExecutorService knxScheduler, - StatusUpdateCallback statusUpdateCallback) { + public IPClient(IpConnectionType ipConnectionType, String ip, String localSource, int port, + @Nullable InetSocketAddress localEndPoint, boolean useNAT, int autoReconnectPeriod, + byte[] secureRoutingBackboneGroupKey, long secureRoutingLatencyToleranceMs, byte[] secureTunnelDevKey, + int secureTunnelUser, byte[] secureTunnelUserKey, ThingUID thingUID, int responseTimeout, int readingPause, + int readRetriesLimit, ScheduledExecutorService knxScheduler, StatusUpdateCallback statusUpdateCallback) { super(autoReconnectPeriod, thingUID, responseTimeout, readingPause, readRetriesLimit, knxScheduler, statusUpdateCallback); this.ipConnectionType = ipConnectionType; @@ -71,10 +89,16 @@ public IPClient(int ipConnectionType, String ip, String localSource, int port, this.port = port; this.localEndPoint = localEndPoint; this.useNAT = useNAT; + this.secureRoutingBackboneGroupKey = secureRoutingBackboneGroupKey; + this.secureRoutingLatencyToleranceMs = secureRoutingLatencyToleranceMs; + this.secureTunnelDevKey = secureTunnelDevKey; + this.secureTunnelUser = secureTunnelUser; + this.secureTunnelUserKey = secureTunnelUserKey; } @Override protected KNXNetworkLink establishConnection() throws KNXException, InterruptedException { + logger.trace("IPBridgeThingHandler::establishConnection"); logger.debug("Establishing connection to KNX bus on {}:{} in mode {}.", ip, port, connectionTypeToString()); TPSettings settings = new TPSettings(new IndividualAddress(localSource)); return createKNXNetworkLinkIP(ipConnectionType, localEndPoint, new InetSocketAddress(ip, port), useNAT, @@ -82,23 +106,41 @@ protected KNXNetworkLink establishConnection() throws KNXException, InterruptedE } private String connectionTypeToString() { - return ipConnectionType == CustomKNXNetworkLinkIP.ROUTING ? MODE_ROUTER : MODE_TUNNEL; + if (ipConnectionType == IpConnectionType.ROUTER) + return MODE_ROUTER; + if (ipConnectionType == IpConnectionType.TUNNEL) + return MODE_TUNNEL; + if (ipConnectionType == IpConnectionType.SECURE_ROUTER) + return MODE_SECURE_ROUTER; + if (ipConnectionType == IpConnectionType.SECURE_TUNNEL) + return MODE_SECURE_TUNNEL; + return "unknown connection type"; } - private KNXNetworkLinkIP createKNXNetworkLinkIP(int serviceMode, @Nullable InetSocketAddress localEP, - @Nullable InetSocketAddress remoteEP, boolean useNAT, KNXMediumSettings settings) - throws KNXException, InterruptedException { + private KNXNetworkLinkIP createKNXNetworkLinkIP(IpConnectionType ipConnectionType, + @Nullable InetSocketAddress localEP, @Nullable InetSocketAddress remoteEP, boolean useNAT, + KNXMediumSettings settings) throws KNXException, InterruptedException { + logger.trace("IPBridgeThingHandler::createKNXNetworkLinkIP"); + // Calimero service mode, ROUTING for both classic and secure routing + int serviceMode = CustomKNXNetworkLinkIP.ROUTING; + if (ipConnectionType == IpConnectionType.TUNNEL) + serviceMode = CustomKNXNetworkLinkIP.TUNNELING; + else if (ipConnectionType == IpConnectionType.SECURE_TUNNEL) + serviceMode = CustomKNXNetworkLinkIP.TUNNELINGV2; + // creating the connection here as a workaround for // https://github.com/calimero-project/calimero-core/issues/57 - KNXnetIPConnection conn = getConnection(serviceMode, localEP, remoteEP, useNAT); + KNXnetIPConnection conn = getConnection(ipConnectionType, localEP, remoteEP, useNAT); return new CustomKNXNetworkLinkIP(serviceMode, conn, settings); } - private KNXnetIPConnection getConnection(int serviceMode, @Nullable InetSocketAddress localEP, + private KNXnetIPConnection getConnection(IpConnectionType ipConnectionType, @Nullable InetSocketAddress localEP, @Nullable InetSocketAddress remoteEP, boolean useNAT) throws KNXException, InterruptedException { KNXnetIPConnection conn; - switch (serviceMode) { - case CustomKNXNetworkLinkIP.TUNNELING: + logger.trace("IPBridgeThingHandler::getConnection"); + switch (ipConnectionType) { + case TUNNEL: + case SECURE_TUNNEL: InetSocketAddress local = localEP; if (local == null) { try { @@ -107,9 +149,25 @@ private KNXnetIPConnection getConnection(int serviceMode, @Nullable InetSocketAd throw new KNXException("no local host available"); } } - conn = new KNXnetIPTunnel(TunnelingLayer.LinkLayer, local, remoteEP, useNAT); + if (ipConnectionType == IpConnectionType.SECURE_TUNNEL) { + // the follwing would create a secure conn via UDP, default should be TCP + // conn = SecureConnection.newTunneling(TunnelingLayer.LinkLayer, local, remoteEP, useNAT, + // secureTunnelDevKey, secureTunnelUser, secureTunnelUserKey); + + // TODO: possibly implement TCP connection management + logger.debug("creating new TCP connection"); + // using .clone for the keys is essential - otherwise Calimero clears the array and a reconnect will + // fail + final var session = TcpConnection.newTcpConnection(localEP, remoteEP).newSecureSession( + secureTunnelUser, secureTunnelUserKey.clone(), secureTunnelDevKey.clone()); + logger.debug("creating new tunnel"); + conn = SecureConnection.newTunneling(TunnelingLayer.LinkLayer, session, + new IndividualAddress(localSource)); + } else + conn = new KNXnetIPTunnel(TunnelingLayer.LinkLayer, local, remoteEP, useNAT); break; - case CustomKNXNetworkLinkIP.ROUTING: + case ROUTER: + case SECURE_ROUTER: NetworkInterface netIf = null; if (localEP != null && !localEP.isUnresolved()) { try { @@ -119,7 +177,11 @@ private KNXnetIPConnection getConnection(int serviceMode, @Nullable InetSocketAd } } final InetAddress mcast = remoteEP != null ? remoteEP.getAddress() : null; - conn = new KNXnetIPRouting(netIf, mcast); + if (ipConnectionType == IpConnectionType.SECURE_ROUTER) + conn = SecureConnection.newRouting(netIf, mcast, secureRoutingBackboneGroupKey, + Duration.ofMillis(secureRoutingLatencyToleranceMs)); + else + conn = new KNXnetIPRouting(netIf, mcast); break; default: throw new KNXIllegalArgumentException("unknown service mode"); diff --git a/bundles/org.openhab.binding.knx/src/main/java/org/openhab/binding/knx/internal/client/SerialClient.java b/bundles/org.openhab.binding.knx/src/main/java/org/openhab/binding/knx/internal/client/SerialClient.java index af44bef4a452a..274c0a3029673 100644 --- a/bundles/org.openhab.binding.knx/src/main/java/org/openhab/binding/knx/internal/client/SerialClient.java +++ b/bundles/org.openhab.binding.knx/src/main/java/org/openhab/binding/knx/internal/client/SerialClient.java @@ -38,20 +38,27 @@ public class SerialClient extends AbstractKNXClient { private final Logger logger = LoggerFactory.getLogger(SerialClient.class); private final String serialPort; + private final boolean useCemi; public SerialClient(int autoReconnectPeriod, ThingUID thingUID, int responseTimeout, int readingPause, - int readRetriesLimit, ScheduledExecutorService knxScheduler, String serialPort, + int readRetriesLimit, ScheduledExecutorService knxScheduler, String serialPort, boolean useCemi, StatusUpdateCallback statusUpdateCallback) { super(autoReconnectPeriod, thingUID, responseTimeout, readingPause, readRetriesLimit, knxScheduler, statusUpdateCallback); this.serialPort = serialPort; + this.useCemi = useCemi; } @Override protected @NonNull KNXNetworkLink establishConnection() throws KNXException, InterruptedException { try { RXTXVersion.getVersion(); - logger.debug("Establishing connection to KNX bus through FT1.2 on serial port {}.", serialPort); + logger.debug("Establishing connection to KNX bus through FT1.2 on serial port {}" + + (useCemi ? " using CEMI" : "") + ".", serialPort); + // cemi support by Calimero library, may be userful for newer serial devices like KNX RF sticks, kBerry, + // etc. + if (useCemi) + return KNXNetworkLinkFT12.newCemiLink(serialPort, new TPSettings()); return new KNXNetworkLinkFT12(serialPort, new TPSettings()); } catch (NoClassDefFoundError e) { diff --git a/bundles/org.openhab.binding.knx/src/main/java/org/openhab/binding/knx/internal/config/BridgeConfiguration.java b/bundles/org.openhab.binding.knx/src/main/java/org/openhab/binding/knx/internal/config/BridgeConfiguration.java index 1d12d324f16c4..3a728cfef2a0a 100644 --- a/bundles/org.openhab.binding.knx/src/main/java/org/openhab/binding/knx/internal/config/BridgeConfiguration.java +++ b/bundles/org.openhab.binding.knx/src/main/java/org/openhab/binding/knx/internal/config/BridgeConfiguration.java @@ -14,10 +14,8 @@ import java.math.BigDecimal; -import org.openhab.binding.knx.internal.handler.KNXBridgeBaseThingHandler; - /** - * {@link KNXBridgeBaseThingHandler} configuration + * {@link org.openhab.binding.knx.internal.handler.KNXBridgeBaseThingHandler} configuration * * @author Simon Kaufmann - initial contribution and API * @@ -27,6 +25,8 @@ public class BridgeConfiguration { private BigDecimal readingPause; private BigDecimal readRetriesLimit; private BigDecimal responseTimeout; + private String keyringFile; + private String keyringPassword; public int getAutoReconnectPeriod() { return autoReconnectPeriod; @@ -47,4 +47,12 @@ public BigDecimal getResponseTimeout() { public void setAutoReconnectPeriod(int period) { autoReconnectPeriod = period; } + + public String getKeyringFile() { + return keyringFile; + } + + public String getKeyringPassword() { + return keyringPassword; + } } diff --git a/bundles/org.openhab.binding.knx/src/main/java/org/openhab/binding/knx/internal/config/IPBridgeConfiguration.java b/bundles/org.openhab.binding.knx/src/main/java/org/openhab/binding/knx/internal/config/IPBridgeConfiguration.java index b3192f7b826fd..8ebdeee11c725 100644 --- a/bundles/org.openhab.binding.knx/src/main/java/org/openhab/binding/knx/internal/config/IPBridgeConfiguration.java +++ b/bundles/org.openhab.binding.knx/src/main/java/org/openhab/binding/knx/internal/config/IPBridgeConfiguration.java @@ -28,6 +28,11 @@ public class IPBridgeConfiguration extends BridgeConfiguration { private BigDecimal portNumber; private String localIp; private String localSourceAddr; + private String routerBackboneKey; + private String tunnelUserId; + private String tunnelUserPassword; + private String tunnelDeviceAuthentication; + private String tunnelSourceAddress; public Boolean getUseNAT() { return useNAT; @@ -52,4 +57,24 @@ public String getLocalIp() { public String getLocalSourceAddr() { return localSourceAddr; } + + public String getRouterBackboneKey() { + return routerBackboneKey; + } + + public String getTunnelUserId() { + return tunnelUserId; + } + + public String getTunnelUserPassword() { + return tunnelUserPassword; + } + + public String getTunnelDeviceAuthentication() { + return tunnelDeviceAuthentication; + } + + public String getTunnelSourceAddress() { + return tunnelSourceAddress; + } } diff --git a/bundles/org.openhab.binding.knx/src/main/java/org/openhab/binding/knx/internal/config/SerialBridgeConfiguration.java b/bundles/org.openhab.binding.knx/src/main/java/org/openhab/binding/knx/internal/config/SerialBridgeConfiguration.java index 8dfcd9cf3a467..3edc02ec7872c 100644 --- a/bundles/org.openhab.binding.knx/src/main/java/org/openhab/binding/knx/internal/config/SerialBridgeConfiguration.java +++ b/bundles/org.openhab.binding.knx/src/main/java/org/openhab/binding/knx/internal/config/SerialBridgeConfiguration.java @@ -21,8 +21,13 @@ public class SerialBridgeConfiguration extends BridgeConfiguration { private String serialPort; + private boolean useCemi; public String getSerialPort() { return serialPort; } + + public boolean useCemi() { + return useCemi; + } } diff --git a/bundles/org.openhab.binding.knx/src/main/java/org/openhab/binding/knx/internal/handler/DeviceThingHandler.java b/bundles/org.openhab.binding.knx/src/main/java/org/openhab/binding/knx/internal/handler/DeviceThingHandler.java index 2e5ff3c8fd525..7b91d746b3e16 100644 --- a/bundles/org.openhab.binding.knx/src/main/java/org/openhab/binding/knx/internal/handler/DeviceThingHandler.java +++ b/bundles/org.openhab.binding.knx/src/main/java/org/openhab/binding/knx/internal/handler/DeviceThingHandler.java @@ -72,8 +72,8 @@ public class DeviceThingHandler extends AbstractKNXThingHandler { private final Set groupAddresses = new HashSet<>(); private final Set groupAddressesWriteBlockedOnce = new HashSet<>(); private final Set groupAddressesRespondingSpec = new HashSet<>(); - private final Map> readFutures = new HashMap<>(); - private final Map> channelFutures = new HashMap<>(); + private final Map> readFutures = new HashMap<>(); + private final Map> channelFutures = new HashMap<>(); private int readInterval; public DeviceThingHandler(Thing thing) { diff --git a/bundles/org.openhab.binding.knx/src/main/java/org/openhab/binding/knx/internal/handler/IPBridgeThingHandler.java b/bundles/org.openhab.binding.knx/src/main/java/org/openhab/binding/knx/internal/handler/IPBridgeThingHandler.java index e40f126f6c40e..d2e20ca75312f 100644 --- a/bundles/org.openhab.binding.knx/src/main/java/org/openhab/binding/knx/internal/handler/IPBridgeThingHandler.java +++ b/bundles/org.openhab.binding.knx/src/main/java/org/openhab/binding/knx/internal/handler/IPBridgeThingHandler.java @@ -14,11 +14,11 @@ import java.net.InetSocketAddress; import java.text.MessageFormat; +import java.util.concurrent.Future; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; import org.openhab.binding.knx.internal.KNXBindingConstants; -import org.openhab.binding.knx.internal.client.CustomKNXNetworkLinkIP; import org.openhab.binding.knx.internal.client.IPClient; import org.openhab.binding.knx.internal.client.KNXClient; import org.openhab.binding.knx.internal.client.NoOpClient; @@ -30,6 +30,9 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import tuwien.auto.calimero.secure.KnxSecureException; +import tuwien.auto.calimero.secure.Security; + /** * The {@link IPBridgeThingHandler} is responsible for handling commands, which are * sent to one of the channels. It implements a KNX/IP Gateway, that either acts a a @@ -43,6 +46,9 @@ public class IPBridgeThingHandler extends KNXBridgeBaseThingHandler { private static final String MODE_ROUTER = "ROUTER"; private static final String MODE_TUNNEL = "TUNNEL"; + private static final String MODE_SECURE_ROUTER = "SECUREROUTER"; + private static final String MODE_SECURE_TUNNEL = "SECURETUNNEL"; + private @Nullable Future initJob = null; private final Logger logger = LoggerFactory.getLogger(IPBridgeThingHandler.class); @@ -56,7 +62,47 @@ public IPBridgeThingHandler(Bridge bridge, NetworkAddressService networkAddressS @Override public void initialize() { + // initialisation would take too long and show a warning during binding startup + // KNX secure is adding serious delay + initJob = scheduler.submit(() -> { + initializeLater(); + }); + } + + public void initializeLater() { + logger.trace("KNX scheduled initialization started"); + IPBridgeConfiguration config = getConfigAs(IPBridgeConfiguration.class); + boolean securityAvailable = false; + try { + if (!config.getKeyringFile().isEmpty()) + logger.info("KNX secure: keyring is configured, opening may take some time"); + + securityAvailable = initializeSecurity(config.getKeyringFile(), config.getKeyringPassword(), + config.getRouterBackboneKey(), config.getTunnelDeviceAuthentication(), config.getTunnelUserId(), + config.getTunnelUserPassword(), config.getTunnelSourceAddress()); + if (securityAvailable) { + logger.info("KNX secure: router backboneGroupKey is " + + ((secureRouting.backboneGroupKey.length == 16) ? "properly" : "not") + " set"); + boolean tunnelOk = ((secureTunnel.user > 0) && (secureTunnel.devKey.length == 16) + && (secureTunnel.userKey.length == 16)); + logger.info("KNX secure: tunnel keys are " + (tunnelOk ? "properly" : "not") + " set"); + logger.info("KNX secure: keyring is " + ((keyring.isPresent()) ? "" : "not ") + "available"); + if (keyring.isPresent()) { + logger.info("KNX secure available for {} devices, {} group addresses", + Security.defaultInstallation().deviceToolKeys().size(), + Security.defaultInstallation().groupKeys().size()); + + logger.debug("Secure group addresses and associated devices: {}", getSecreGroupAdresses()); + } + + } else { + logger.debug("KNX security not configured"); + } + } catch (KnxSecureException e) { + logger.warn("{}", e.toString()); + } + int autoReconnectPeriod = config.getAutoReconnectPeriod(); if (autoReconnectPeriod != 0 && autoReconnectPeriod < 30) { logger.info("autoReconnectPeriod for {} set to {}s, allowed range is 0 (never) or >30", thing.getUID(), @@ -70,29 +116,69 @@ public void initialize() { String ip = config.getIpAddress(); InetSocketAddress localEndPoint = null; boolean useNAT = false; - int ipConnectionType; + + IPClient.IpConnectionType ipConnectionType; if (MODE_TUNNEL.equalsIgnoreCase(connectionTypeString)) { useNAT = config.getUseNAT() != null ? config.getUseNAT() : false; - ipConnectionType = CustomKNXNetworkLinkIP.TUNNELING; + ipConnectionType = IPClient.IpConnectionType.TUNNEL; + } else if (MODE_SECURE_TUNNEL.equalsIgnoreCase(connectionTypeString)) { + useNAT = config.getUseNAT() != null ? config.getUseNAT() : false; + ipConnectionType = IPClient.IpConnectionType.SECURE_TUNNEL; + + if (!securityAvailable) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, + "Security configuration missing for secure tunnel"); + return; + } + boolean tunnelOk = ((secureTunnel.user > 0) && (secureTunnel.devKey.length == 16) + && (secureTunnel.userKey.length == 16)); + if (!tunnelOk) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, + "Security configuration for secure tunnel is incomplete"); + return; + } + + logger.info("KNX secure tunneling needs a few seconds to establish connection"); + // user id, key, devAuth are already stored } else if (MODE_ROUTER.equalsIgnoreCase(connectionTypeString)) { useNAT = false; if (ip == null || ip.isEmpty()) { ip = KNXBindingConstants.DEFAULT_MULTICAST_IP; } - ipConnectionType = CustomKNXNetworkLinkIP.ROUTING; + ipConnectionType = IPClient.IpConnectionType.ROUTER; + } else if (MODE_SECURE_ROUTER.equalsIgnoreCase(connectionTypeString)) { + useNAT = false; + if (ip == null || ip.isEmpty()) { + ip = KNXBindingConstants.DEFAULT_MULTICAST_IP; + } + ipConnectionType = IPClient.IpConnectionType.SECURE_ROUTER; + + if (!securityAvailable) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, + "Security configuration missing for secure routing"); + return; + } + if (secureRouting.backboneGroupKey.length != 16) { + // failed to read shared backbone group key from config or keyring + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, + "backboneGroupKey required for secure routing; please check keyring or configure manually"); + return; + } + logger.info("KNX secure routing needs a few seconds to establish connection"); } else { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, - MessageFormat.format("Unknown IP connection type {0}. Known types are either 'TUNNEL' or 'ROUTER'", - connectionTypeString)); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, MessageFormat.format( + "Unknown IP connection type {0}. Known types are either 'TUNNEL', 'ROUTER', 'SECURETUNNEL', or 'SECUREROUTER'", + connectionTypeString)); return; } if (ip == null) { + // this applies only to tunnel, as router modes set multicast address above updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, - "The 'ipAddress' of the gateway must be configured in 'TUNNEL' mode"); + "The 'ipAddress' of the gateway must be configured in '*TUNNEL' modes"); return; } - if (config.getLocalIp() != null && !config.getLocalIp().isEmpty()) { + if (!config.getLocalIp().isEmpty()) { localEndPoint = new InetSocketAddress(config.getLocalIp(), 0); } else { localEndPoint = new InetSocketAddress(networkAddressService.getPrimaryIpv4HostAddress(), 0); @@ -100,19 +186,24 @@ public void initialize() { updateStatus(ThingStatus.UNKNOWN); client = new IPClient(ipConnectionType, ip, localSource, port, localEndPoint, useNAT, autoReconnectPeriod, - thing.getUID(), config.getResponseTimeout().intValue(), config.getReadingPause().intValue(), - config.getReadRetriesLimit().intValue(), getScheduler(), this); + secureRouting.backboneGroupKey, secureRouting.latencyToleranceMs, secureTunnel.devKey, + secureTunnel.user, secureTunnel.userKey, thing.getUID(), config.getResponseTimeout().intValue(), + config.getReadingPause().intValue(), config.getReadRetriesLimit().intValue(), getScheduler(), this); client.initialize(); + + logger.trace("KNX scheduled initialization completed"); } @Override public void dispose() { - super.dispose(); + if (initJob != null) + initJob.cancel(true); if (client != null) { client.dispose(); client = null; } + super.dispose(); } @Override diff --git a/bundles/org.openhab.binding.knx/src/main/java/org/openhab/binding/knx/internal/handler/KNXBridgeBaseThingHandler.java b/bundles/org.openhab.binding.knx/src/main/java/org/openhab/binding/knx/internal/handler/KNXBridgeBaseThingHandler.java index e9c087b4acf5a..29fd0e95aedda 100644 --- a/bundles/org.openhab.binding.knx/src/main/java/org/openhab/binding/knx/internal/handler/KNXBridgeBaseThingHandler.java +++ b/bundles/org.openhab.binding.knx/src/main/java/org/openhab/binding/knx/internal/handler/KNXBridgeBaseThingHandler.java @@ -12,6 +12,15 @@ */ package org.openhab.binding.knx.internal.handler; +import java.io.File; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.Optional; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; @@ -20,6 +29,7 @@ import org.eclipse.jdt.annotation.Nullable; import org.openhab.binding.knx.internal.client.KNXClient; import org.openhab.binding.knx.internal.client.StatusUpdateCallback; +import org.openhab.core.OpenHAB; import org.openhab.core.common.ThreadPoolManager; import org.openhab.core.thing.Bridge; import org.openhab.core.thing.ChannelUID; @@ -28,28 +38,335 @@ import org.openhab.core.thing.binding.BaseBridgeHandler; import org.openhab.core.types.Command; +import tuwien.auto.calimero.GroupAddress; import tuwien.auto.calimero.IndividualAddress; +import tuwien.auto.calimero.KNXFormatException; +import tuwien.auto.calimero.knxnetip.SecureConnection; import tuwien.auto.calimero.mgmt.Destination; +import tuwien.auto.calimero.secure.Keyring; +import tuwien.auto.calimero.secure.Keyring.Backbone; +import tuwien.auto.calimero.secure.Keyring.Interface; +import tuwien.auto.calimero.secure.KnxSecureException; +import tuwien.auto.calimero.secure.Security; /** * The {@link KNXBridgeBaseThingHandler} is responsible for handling commands, which are * sent to one of the channels. * * @author Simon Kaufmann - Initial contribution and API + * -- */ @NonNullByDefault public abstract class KNXBridgeBaseThingHandler extends BaseBridgeHandler implements StatusUpdateCallback { + @NonNullByDefault + public static class SecureTunnelConfig { + public SecureTunnelConfig() { + devKey = new byte[0]; + userKey = new byte[0]; + user = 0; + } + + public byte[] devKey; + public byte[] userKey; + public int user = 0; + } + + @NonNullByDefault + public static class SecureRoutingConfig { + public SecureRoutingConfig() { + backboneGroupKey = new byte[0]; + latencyToleranceMs = 0; + } + + public byte[] backboneGroupKey; + public long latencyToleranceMs = 0; + } + protected ConcurrentHashMap destinations = new ConcurrentHashMap<>(); private final ScheduledExecutorService knxScheduler = ThreadPoolManager.getScheduledPool("knx"); private final ScheduledExecutorService backgroundScheduler = Executors.newSingleThreadScheduledExecutor(); + protected Optional keyring; + // password used to protect content of the keyring + private String keyringPassword = ""; + // backbone key (shared password used for secure router mode) + + protected SecureRoutingConfig secureRouting; + protected SecureTunnelConfig secureTunnel; public KNXBridgeBaseThingHandler(Bridge bridge) { super(bridge); + keyring = Optional.empty(); + secureRouting = new SecureRoutingConfig(); + secureTunnel = new SecureTunnelConfig(); } protected abstract KNXClient getClient(); + /*** + * Initialize KNX secure if configured (simple interface) + * + * @param cKeyringFile keyring file, exported from ETS tool + * @param cKeyringPassword keyring password, set during export from ETS tool + * @return + */ + protected boolean initializeSecurity(String cKeyringFile, String cKeyringPassword) { + return initializeSecurity(cKeyringFile, cKeyringPassword, "", "", "", "", ""); + } + + /*** + * Initialize KNX secure if configured (full interface) + * + * @param cKeyringFile keyring file, exported from ETS tool + * @param cKeyringPassword keyring password, set during export from ETS tool + * @param cRouterBackboneGroupKey shared key for secure router mode. If not given, it will be read from keyring. + * @param cTunnelDevAuth device password for IP interface in tunnel mode. If not given it will be read from keyring + * if cTunnelSourceAddr is configured. + * @param cTunnelUser user id for tunnel mode. Must be an integer >0. If not given it will be read from keyring if + * cTunnelSourceAddr is configured. + * @param cTunnelPassword user password for tunnel mode. If not given it will be read from keyring if + * cTunnelSourceAddr is configured. + * @param cTunnelSourceAddr specify the KNX address to uniquely identify a tunnel connection in secure tunneling + * mode. Not required if cTunnelDevAuth, cTunnelUser, and cTunnelPassword are given. + * @return + */ + protected boolean initializeSecurity(String cKeyringFile, String cKeyringPassword, String cRouterBackboneGroupKey, + String cTunnelDevAuth, String cTunnelUser, String cTunnelPassword, String cTunnelSourceAddr) { + keyring = Optional.empty(); + keyringPassword = ""; + IndividualAddress secureTunnelSourceAddr = null; + secureRouting = new SecureRoutingConfig(); + secureTunnel = new SecureTunnelConfig(); + + boolean securityInitialized = false; + + // step 1: secure routing, backbone group key manually specified in OH config (typically it is read from + // keyring) + if (!cRouterBackboneGroupKey.isBlank()) { + // provided in conig, this will override whatever is read from keyring + String tmp = cRouterBackboneGroupKey.trim().replaceFirst("^0x", "").trim().replace(" ", ""); + if (!tmp.isEmpty()) { + // helper may throw KnxSecureException + secureRouting.backboneGroupKey = secHelperParseBackboneKey(tmp); + securityInitialized = true; + } + } + + // step 2: check if valid tunnel parameters are specified in config + if (!cTunnelSourceAddr.isBlank()) { + try { + secureTunnelSourceAddr = new IndividualAddress(cTunnelSourceAddr.trim()); + securityInitialized = true; + } catch (KNXFormatException e) { + throw new KnxSecureException("tunnel source adderss cannot be parsed, valid format is x.y.z"); + } + } + if (!cTunnelDevAuth.isBlank()) { + String pwd = cTunnelDevAuth.trim(); + secureTunnel.devKey = SecureConnection.hashDeviceAuthenticationPassword(pwd.toCharArray()); + securityInitialized = true; + } + if (!cTunnelPassword.isBlank()) { + String pwd = cTunnelPassword.trim(); + secureTunnel.userKey = SecureConnection.hashUserPassword(pwd.toCharArray()); + securityInitialized = true; + } + if (!cTunnelUser.isBlank()) { + String user = cTunnelUser.trim(); + try { + secureTunnel.user = Integer.decode(user); + } catch (NumberFormatException e) { + throw new KnxSecureException("tunnelUser must be a number >0"); + } + if (secureTunnel.user <= 0) + throw new KnxSecureException("tunnelUser must be a number >0"); + securityInitialized = true; + } + + // step 3: keyring + if (!cKeyringFile.isBlank()) { + // filename defined in config, start parsing + try { + // load keyring file from config dir, folder misc + String keyringUri = OpenHAB.getConfigFolder() + File.separator + "misc" + File.separator + + cKeyringFile.trim(); + keyring = Optional.ofNullable(Keyring.load(keyringUri)); + if (!keyring.isPresent()) + throw new KnxSecureException("keyring file configured, but loading failed: " + keyringUri); + + // loading was successful, check signatures + // -> disabled, as Calimero v2.5 does this within the load() function + // if (!keyring.verifySignature(cKeyringPassword.toCharArray())) + // throw new KnxSecureException( + // "signature verification failed, please check keyring file: " + keyringUri); + keyringPassword = cKeyringPassword; + + // Add to global static key(ring) storage of Calimero library. + // More than one can be added ONLY IF addresses are different, + // as Calimero adds all information to this static object. + Security.defaultInstallation().useKeyring(keyring.get(), keyringPassword.toCharArray()); + + securityInitialized = true; + } catch (KnxSecureException e) { + keyring = Optional.empty(); + keyringPassword = ""; + throw e; + } + } + + // step 4: router: load backboneGroupKey from keyring if not manually specified + if ((secureRouting.backboneGroupKey.length == 0) && (keyring.isPresent())) { + // backbone group key is only available if secure routers are present + final Optional key = secHelperReadBackboneKey(keyring, keyringPassword); + if (key.isPresent()) { + secureRouting.backboneGroupKey = key.get(); + securityInitialized = true; + } + } + + // step 5: router: load latencyTolerance + // default to 2000ms + // this parameter is currently not exposed in config, in case it must be set by using the keyring + secureRouting.latencyToleranceMs = 2000; + if (keyring.isPresent()) { + // backbone latency is only relevant if secure routers are present + final Optional bb = keyring.get().backbone(); + if (bb.isPresent()) { + final long toleranceMs = bb.get().latencyTolerance().toMillis(); + secureRouting.latencyToleranceMs = toleranceMs; + } + } + + // step 6: tunnel: load data from keyring + if (secureTunnelSourceAddr != null) { + // requires a valid keyring + if (!keyring.isPresent()) + throw new KnxSecureException("valid keyring specification required for secure tunnel mode"); + // other parameters will not be accepted, since all is read from keyring in this case + if ((secureTunnel.userKey.length > 0) || secureTunnel.user != 0 || (secureTunnel.devKey.length > 0)) + throw new KnxSecureException( + "tunnelSourceAddr is configured, please do not specify other parametes of secure tunnel"); + + Optional config = secHelperReadTunnelConfig(keyring, keyringPassword, + secureTunnelSourceAddr); + if (config.isEmpty()) + throw new KnxSecureException("tunnel definition cannot be read from keyring"); + + secureTunnel = config.get(); + } + return securityInitialized; + } + + /*** + * converts hex string (32 characters) to byte[16] + * + * @param hexstring 32 characters hex + * @return key in byte array format + */ + public static byte[] secHelperParseBackboneKey(String hexstring) throws NumberFormatException, KnxSecureException { + if ((hexstring == null) || (hexstring.length() != 32)) { + throw new KnxSecureException("backbone key must be 32 characters (16 byte hex notation)"); + } + + byte[] parsed = new byte[16]; + try { + for (byte i = 0; i < 16; i++) { + parsed[i] = (byte) Integer.parseInt(hexstring.substring(2 * i, 2 * i + 2), 16); + } + } catch (NumberFormatException e) { + throw new KnxSecureException("backbone key configured, cannot parse hex string, illegal character"); + } + return parsed; + } + + public static Optional secHelperReadBackboneKey(Optional keyring, String keyringPassword) { + if (keyring.isEmpty()) + throw new KnxSecureException("keyring not available, cannot read backbone key"); + final Optional bb = keyring.get().backbone(); + if (bb.isPresent()) { + final Optional gk = bb.get().groupKey(); + if (gk.isPresent()) { + byte[] secureRoutingBackboneGroupKey = keyring.get().decryptKey(gk.get(), + keyringPassword.toCharArray()); + if (secureRoutingBackboneGroupKey.length != 16) + throw new KnxSecureException("backbone key found, unexpected length != 16"); + + return Optional.of(secureRoutingBackboneGroupKey); + } + } + return Optional.empty(); + } + + public static Optional secHelperReadTunnelConfig(Optional keyring, + String keyringPassword, IndividualAddress secureTunnelSourceAddr) { + if (keyring.isEmpty()) + throw new KnxSecureException("keyring not available, cannot read tunnel config"); + // iterate all interfaces to find matching secureTunnelSourceAddr + SecureTunnelConfig secureTunnel = new SecureTunnelConfig(); + Iterator> itInterface = keyring.get().interfaces().values().iterator(); + boolean complete = false; + while (!complete && itInterface.hasNext()) { + List eInterface = itInterface.next(); + // tunnels are nested + Iterator itTunnel = eInterface.iterator(); + while (!complete && itTunnel.hasNext()) { + Interface eTunnel = itTunnel.next(); + + if (secureTunnelSourceAddr.equals(eTunnel.address())) { + + String pw = ""; + final Optional pwBytes = eTunnel.password(); + if (pwBytes.isPresent()) { + pw = new String(keyring.get().decryptPassword(pwBytes.get(), keyringPassword.toCharArray())); + secureTunnel.userKey = SecureConnection.hashUserPassword(pw.toCharArray()); + } + + String au = ""; + final Optional auBytes = eTunnel.authentication(); + if (auBytes.isPresent()) { + au = new String(keyring.get().decryptPassword(auBytes.get(), keyringPassword.toCharArray())); + secureTunnel.devKey = SecureConnection.hashDeviceAuthenticationPassword(au.toCharArray()) + .clone(); + } + + // set user, 0=fail + secureTunnel.user = eTunnel.user(); + + return Optional.of(secureTunnel); + } + } + } + return Optional.empty(); + } + + /*** + * Show all secure group adresses and surrogates. A surrogate is the device which is asked to carry out an indirect + * read/write request. + * Simpler approach w/o surrogates: Security.defaultInstallation().groupSenders().toString()); + */ + String getSecreGroupAdresses() { + Map> groupSendersWithSurrogate = new HashMap>(); + final Map> senders = Security.defaultInstallation().groupSenders(); + for (var entry : senders.entrySet()) { + final GroupAddress ga = entry.getKey(); + // the following aproach is uses by Calimero to decuce the surrogate for GA diagnostics + // see calimero-core security/SecureApplicationLayer.java, surrogate(...) + final IndividualAddress surrogate = null; + try { + senders.getOrDefault(ga, Set.of()).stream().findAny().get(); + } catch (NoSuchElementException e) { + } + Set devices = new HashSet(); + for (var device : entry.getValue()) + if (device.equals(surrogate)) + devices.add(device.toString() + " (S)"); + else + devices.add(device.toString()); + groupSendersWithSurrogate.put(ga, devices); + } + return groupSendersWithSurrogate.toString(); + } + @Override public void handleCommand(ChannelUID channelUID, Command command) { // Nothing to do here diff --git a/bundles/org.openhab.binding.knx/src/main/java/org/openhab/binding/knx/internal/handler/SerialBridgeThingHandler.java b/bundles/org.openhab.binding.knx/src/main/java/org/openhab/binding/knx/internal/handler/SerialBridgeThingHandler.java index 5e6738646a463..b2570174388b8 100644 --- a/bundles/org.openhab.binding.knx/src/main/java/org/openhab/binding/knx/internal/handler/SerialBridgeThingHandler.java +++ b/bundles/org.openhab.binding.knx/src/main/java/org/openhab/binding/knx/internal/handler/SerialBridgeThingHandler.java @@ -12,12 +12,20 @@ */ package org.openhab.binding.knx.internal.handler; +import java.util.concurrent.Future; + import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; import org.openhab.binding.knx.internal.client.AbstractKNXClient; import org.openhab.binding.knx.internal.client.SerialClient; import org.openhab.binding.knx.internal.config.SerialBridgeConfiguration; import org.openhab.core.thing.Bridge; import org.openhab.core.thing.ThingStatus; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import tuwien.auto.calimero.secure.KnxSecureException; +import tuwien.auto.calimero.secure.Security; /** * The {@link IPBridgeThingHandler} is responsible for handling commands, which are @@ -31,26 +39,52 @@ @NonNullByDefault public class SerialBridgeThingHandler extends KNXBridgeBaseThingHandler { + private final Logger logger = LoggerFactory.getLogger(SerialBridgeThingHandler.class); private final SerialClient client; + private @Nullable Future initJob = null; public SerialBridgeThingHandler(Bridge bridge) { super(bridge); SerialBridgeConfiguration config = getConfigAs(SerialBridgeConfiguration.class); client = new SerialClient(config.getAutoReconnectPeriod(), thing.getUID(), config.getResponseTimeout().intValue(), config.getReadingPause().intValue(), - config.getReadRetriesLimit().intValue(), getScheduler(), config.getSerialPort(), this); + config.getReadRetriesLimit().intValue(), getScheduler(), config.getSerialPort(), config.useCemi(), + this); } @Override public void initialize() { + // initialisation would take too long and show a warning during binding startup + // KNX secure is adding serious delay updateStatus(ThingStatus.UNKNOWN); + initJob = scheduler.submit(() -> { + initializeLater(); + }); + } + + public void initializeLater() { + SerialBridgeConfiguration config = getConfigAs(SerialBridgeConfiguration.class); + try { + if (initializeSecurity(config.getKeyringFile(), config.getKeyringPassword())) + if (keyring.isPresent()) { + logger.info("KNX secure available for {} devices, {} group addresses", + Security.defaultInstallation().deviceToolKeys().size(), + Security.defaultInstallation().groupKeys().size()); + + logger.debug("Secure group addresses and associated devices: {}", getSecreGroupAdresses()); + } + } catch (KnxSecureException e) { + logger.warn("{}", e.toString()); + } client.initialize(); } @Override public void dispose() { - super.dispose(); + if (initJob != null) + initJob.cancel(true); client.dispose(); + super.dispose(); } @Override diff --git a/bundles/org.openhab.binding.knx/src/main/resources/OH-INF/i18n/knx.properties b/bundles/org.openhab.binding.knx/src/main/resources/OH-INF/i18n/knx.properties index 01c6da0176556..1c71cd75309bd 100644 --- a/bundles/org.openhab.binding.knx/src/main/resources/OH-INF/i18n/knx.properties +++ b/bundles/org.openhab.binding.knx/src/main/resources/OH-INF/i18n/knx.properties @@ -39,7 +39,7 @@ thing-type.config.knx.ip.readingPause.description = Time in milliseconds of how thing-type.config.knx.ip.responseTimeout.label = Response Timeout thing-type.config.knx.ip.responseTimeout.description = Seconds to wait for a response from the KNX bus thing-type.config.knx.ip.type.label = IP Connection Type -thing-type.config.knx.ip.type.description = The ip connection type for connecting to the KNX bus. Could be either TUNNEL or ROUTER +thing-type.config.knx.ip.type.description = The ip connection type for connecting to the KNX bus. Could be either TUNNEL, ROUTER, SECURETUNNEL, or SECUREROUTER thing-type.config.knx.ip.type.option.TUNNEL = Tunnel thing-type.config.knx.ip.type.option.ROUTER = Router thing-type.config.knx.ip.useNAT.label = Use NAT diff --git a/bundles/org.openhab.binding.knx/src/main/resources/OH-INF/thing/ip.xml b/bundles/org.openhab.binding.knx/src/main/resources/OH-INF/thing/ip.xml index 89e2c6472f22f..c99e252c90169 100644 --- a/bundles/org.openhab.binding.knx/src/main/resources/OH-INF/thing/ip.xml +++ b/bundles/org.openhab.binding.knx/src/main/resources/OH-INF/thing/ip.xml @@ -9,12 +9,20 @@ This is a KNX IP interface or router + + + Settings for KNX secure. Requires KNX secure features to be active in KNX installation. + true + - The ip connection type for connecting to the KNX bus. Could be either TUNNEL or ROUTER + The ip connection type for connecting to the KNX bus. Could be either TUNNEL, ROUTER, SECURETUNNEL, or + SECUREROUTER + + @@ -64,6 +72,53 @@ Seconds between connection retries when KNX link has been lost, 0 means never retry, minimum 30s 60 + + + KNX secure, optional: Keyring file exported from ETS and placed in openHAB config/misc folder, e.g. + knx.knxkeys. This file is mandatory to decode secure GAs and optional to provide IP secure settings if not + specified. + + + + + KNX secure, optional: Keyring file password (set during export from ETS) + + + + + KNX secure, optional: Backbone key for secure router mode. Typically read from keyring file. 16 bytes + in hex notation. Can also be found in ETS security report. + + + + + KNX secure, optional: Physical KNX address of tunnel in secure mode. Optional, only used in + combination + with keyring file to uniquely identify a tunnel. If given, openHAB will try to read user id, user + password and + device authentication from keyring. + + + + + KNX secure, optional: Tunnel user id for secure tunnel mode. Optional, can be read from keyring file + if + tunnelSourceAddr is configured. + + + + + KNX secure, optional: Tunnel user key for secure tunnel mode. Optional, can be read from keyring file + if tunnelSourceAddr is configured. + + + + + KNX secure, optional: Tunnel device authentication for secure tunnel mode. Optional, can be read from + keyring file if tunnelSourceAddr is configured. + + diff --git a/bundles/org.openhab.binding.knx/src/main/resources/OH-INF/thing/serial.xml b/bundles/org.openhab.binding.knx/src/main/resources/OH-INF/thing/serial.xml index bb7705928889d..27dfd4e9fcf72 100644 --- a/bundles/org.openhab.binding.knx/src/main/resources/OH-INF/thing/serial.xml +++ b/bundles/org.openhab.binding.knx/src/main/resources/OH-INF/thing/serial.xml @@ -8,6 +8,10 @@ This is a serial interface for accessing the KNX bus + + + Settings for KNX secure. Requires KNX secure features to be active in KNX installation. + serial-port @@ -34,6 +38,23 @@ Seconds between connect retries when KNX link has been lost, 0 means never retry 0 + + + Use newer CEMI frame format. May be useful for newer serial devices like KNX RF sticks, kBerry, etc. + false + true + + + + KNX secure, optional: Keyring file exported from ETS and placed in openHAB config/misc folder, e.g. + knx.knxkeys. This file is mandatory to decode secure GAs. + + + + + KNX secure, optional: Keyring file password (set during export from ETS) + + diff --git a/bundles/org.openhab.binding.knx/src/test/java/org/openhab/binding/knx/internal/security/KNXSecurityTest.java b/bundles/org.openhab.binding.knx/src/test/java/org/openhab/binding/knx/internal/security/KNXSecurityTest.java new file mode 100644 index 0000000000000..768d81eefaa1e --- /dev/null +++ b/bundles/org.openhab.binding.knx/src/test/java/org/openhab/binding/knx/internal/security/KNXSecurityTest.java @@ -0,0 +1,125 @@ +/** + * Copyright (c) 2010-2022 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.knx.internal.security; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.Map; +import java.util.Optional; + +import org.junit.jupiter.api.Test; +import org.openhab.binding.knx.internal.handler.KNXBridgeBaseThingHandler; +import org.openhab.binding.knx.internal.handler.KNXBridgeBaseThingHandler.SecureTunnelConfig; + +import tuwien.auto.calimero.GroupAddress; +import tuwien.auto.calimero.IndividualAddress; +import tuwien.auto.calimero.knxnetip.SecureConnection; +import tuwien.auto.calimero.secure.Keyring; +import tuwien.auto.calimero.secure.KnxSecureException; +import tuwien.auto.calimero.secure.Security; + +/** + * + * @author Simon Kaufmann - initial contribution and API + * + */ +public class KNXSecurityTest { + + @Test + public void testCalimero_keyring() { + final String testFile = getClass().getClassLoader().getResource("openhab6.knxkeys").toString(); + final String passwordString = "habopen"; + + final char[] password = passwordString.toCharArray(); + assertNotEquals("", testFile); + + Keyring keys = Keyring.load(testFile); + assertTrue(keys.verifySignature(password)); + + // System.out.println(keys.devices().toString()); + // System.out.println(keys.groups().toString()); + // System.out.println(keys.interfaces().toString()); + + GroupAddress ga = new GroupAddress(8, 0, 0); + byte[] key800enc = keys.groups().get(ga); + assertNotNull(key800enc); + assertNotEquals(0, key800enc.length); + byte[] key800dec = keys.decryptKey(key800enc, password); + assertEquals(16, key800dec.length); + + IndividualAddress nopa = new IndividualAddress(2, 8, 20); + Keyring.Device nodev = keys.devices().get(nopa); + assertNull(nodev); + + IndividualAddress pa = new IndividualAddress(1, 1, 42); + Keyring.Device dev = keys.devices().get(pa); + assertNotNull(dev); + // cannot check this for dummy test file, needs real device to be included + // assertNotEquals(0, dev.sequenceNumber()); + + Security.defaultInstallation().useKeyring(keys, password); + Map groupKeys = Security.defaultInstallation().groupKeys(); + assertEquals(3, groupKeys.size()); + groupKeys.remove(ga); + assertEquals(2, groupKeys.size()); + Security.defaultInstallation().useKeyring(keys, password); + Map groupKeys2 = Security.defaultInstallation().groupKeys(); + assertEquals(3, groupKeys2.size()); + assertEquals(3, groupKeys.size()); + ga = new GroupAddress(1, 0, 0); + groupKeys.put(ga, new byte[1]); + assertEquals(4, groupKeys2.size()); + assertEquals(4, groupKeys.size()); + Security.defaultInstallation().useKeyring(keys, password); + assertEquals(4, groupKeys2.size()); + assertEquals(4, groupKeys.size()); + + // now check router settings: + assertThrows(KnxSecureException.class, () -> { + KNXBridgeBaseThingHandler.secHelperReadBackboneKey(Optional.empty(), passwordString); + }); + String bbKeyHex = "D947B12DDECAD528B1D5A88FD347F284"; + byte[] bbKeyParsedLower = KNXBridgeBaseThingHandler.secHelperParseBackboneKey(bbKeyHex.toLowerCase()); + byte[] bbKeyParsedUpper = KNXBridgeBaseThingHandler.secHelperParseBackboneKey(bbKeyHex); + Optional bbKeyRead = KNXBridgeBaseThingHandler.secHelperReadBackboneKey(Optional.ofNullable(keys), + passwordString); + assertEquals(16, bbKeyParsedUpper.length); + assertArrayEquals(bbKeyParsedUpper, bbKeyParsedLower); + assertTrue(bbKeyRead.isPresent()); + assertArrayEquals(bbKeyParsedUpper, bbKeyRead.get()); + // System.out.print("Backbone key: \""); + // for (byte i : backboneGroupKey) + // System.out.print(String.format("%02X", i)); + // System.out.println("\""); + + // now check tunnel settings: + IndividualAddress secureTunnelSourceAddr = new IndividualAddress(1, 1, 2); + IndividualAddress noSecureTunnelSourceAddr = new IndividualAddress(2, 8, 20); + assertThrows(KnxSecureException.class, () -> { + KNXBridgeBaseThingHandler.secHelperReadTunnelConfig(Optional.empty(), passwordString, + secureTunnelSourceAddr); + }); + assertTrue(KNXBridgeBaseThingHandler + .secHelperReadTunnelConfig(Optional.ofNullable(keys), passwordString, noSecureTunnelSourceAddr) + .isEmpty()); + + Optional config = KNXBridgeBaseThingHandler + .secHelperReadTunnelConfig(Optional.ofNullable(keys), passwordString, secureTunnelSourceAddr); + assertTrue(config.isPresent()); + assertEquals(2, config.get().user); + + assertArrayEquals(SecureConnection.hashUserPassword("mytunnel1".toCharArray()), config.get().userKey); + assertArrayEquals(SecureConnection.hashDeviceAuthenticationPassword("myauthcode".toCharArray()), + config.get().devKey); + } +} diff --git a/bundles/org.openhab.binding.knx/src/test/resources/openhab6.knxkeys b/bundles/org.openhab.binding.knx/src/test/resources/openhab6.knxkeys new file mode 100644 index 0000000000000..1182b17b9b4c6 --- /dev/null +++ b/bundles/org.openhab.binding.knx/src/test/resources/openhab6.knxkeys @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file