package modbus import ( "crypto/tls" "crypto/x509" "crypto/x509/pkix" "encoding/pem" "testing" "time" ) const ( clientCertWithRoleOID string = ` -----BEGIN CERTIFICATE----- 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 hG79y2z0UfKJstN+ -----END CERTIFICATE----- ` clientKeyWithRoleOID string = ` -----BEGIN PRIVATE KEY----- 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 isPLG4c6aPGxSbHirNfl6tBSngDy+A== -----END PRIVATE KEY----- ` ) // TestTLSServer tests the TLS layer of the modbus server. func TestTLSServer(t *testing.T) { var err error var server *ModbusServer var serverKeyPair tls.Certificate var client1KeyPair tls.Certificate var client2KeyPair tls.Certificate var clientCp *x509.CertPool var serverCp *x509.CertPool var th *tlsTestHandler var c1 *ModbusClient var c2 *ModbusClient var regs []uint16 var coils []bool th = &tlsTestHandler{} // load server keypair (from client_tls_test.go) serverKeyPair, err = tls.X509KeyPair([]byte(serverCert), []byte(serverKey)) if err != nil { t.Errorf("failed to load test server key pair: %v", err) return } // load the first client keypair (from client_tls_test.go) // this client cert doesn't have any Modbus Role extension client1KeyPair, err = tls.X509KeyPair([]byte(clientCert), []byte(clientKey)) if err != nil { t.Errorf("failed to load test client key pair: %v", err) return } // load the second client keypair (defined above) // this client cert has an "operator2" Modbus Role extension client2KeyPair, err = tls.X509KeyPair( []byte(clientCertWithRoleOID), []byte(clientKeyWithRoleOID)) if err != nil { t.Errorf("failed to load test client key pair: %v", err) return } // load the server cert into the client CA cert pool to get the server cert // accepted by clients clientCp = x509.NewCertPool() if !clientCp.AppendCertsFromPEM([]byte(serverCert)) { t.Errorf("failed to load test server cert into cert pool") } // start with an empty server cert pool initially to reject the client // certificate serverCp = x509.NewCertPool() server, err = NewServer(&ServerConfiguration{ URL: "tcp+tls://localhost:5802", MaxClients: 2, TLSServerCert: &serverKeyPair, TLSClientCAs: serverCp, }, th) if err != nil { t.Errorf("failed to create server: %v", err) } err = server.Start() if err != nil { t.Errorf("failed to start server: %v", err) } // create 2 modbus clients c1, err = NewClient(&ClientConfiguration{ URL: "tcp+tls://localhost:5802", TLSClientCert: &client1KeyPair, TLSRootCAs: clientCp, }) if err != nil { t.Errorf("failed to create client: %v", err) } c2, err = NewClient(&ClientConfiguration{ URL: "tcp+tls://localhost:5802", TLSClientCert: &client2KeyPair, TLSRootCAs: clientCp, }) if err != nil { t.Errorf("failed to create client: %v", err) } // attempt to connect and use the first client. since its cert // is not trusted by the server, a TLS error should occur on the first // request. err = c1.Open() if err != nil { t.Errorf("c1.Open() should have succeeded") } coils, err = c1.ReadCoils(0, 5) if err == nil { t.Error("c1.ReadCoils() should have failed") } c1.Close() // now place both client certs in the server's authorized client list // to get them past the TLS client cert validation procedure if !serverCp.AppendCertsFromPEM([]byte(clientCert)) { t.Errorf("failed to load client#1 cert into cert pool") } if !serverCp.AppendCertsFromPEM([]byte(clientCertWithRoleOID)) { t.Errorf("failed to load client#2 cert into cert pool") } // connect both clients: should succeed err = c1.Open() if err != nil { t.Error("c1.Open() should have succeeded") } err = c2.Open() if err != nil { t.Error("c2.Open() should have succeeded") } // client #2 (with 'operator2' role) should have read/write access to coils while // client #1 (without role) should only be able to read. err = c1.WriteCoil(0, true) if err != ErrIllegalFunction { t.Errorf("c1.WriteCoil() should have failed with %v, got: %v", ErrIllegalFunction, err) } coils, err = c1.ReadCoils(0, 5) if err != nil { t.Errorf("c1.ReadCoils() should have succeeded, got: %v", err) } if coils[0] { t.Errorf("coils[0] should have been false") } err = c2.WriteCoil(0, true) if err != nil { t.Errorf("c2.WriteCoil() should have succeeded, got: %v", err) } coils, err = c2.ReadCoils(0, 5) if err != nil { t.Errorf("c2.ReadCoils() should have succeeded, got: %v", err) } if !coils[0] { t.Errorf("coils[0] should have been true") } coils, err = c1.ReadCoils(0, 5) if err != nil { t.Errorf("c1.ReadCoils() should have succeeded, got: %v", err) } if !coils[0] { t.Errorf("coils[0] should have been true") } // client #1 should only be allowed access to holding registers of unit id #1 // while client#2 should be allowed access to holding registers of unit ids #1 and #4 c1.SetUnitId(1) err = c1.WriteRegister(2, 100) if err != nil { t.Errorf("c1.WriteRegister() should have succeeded, got: %v", err) } c1.SetUnitId(4) err = c1.WriteRegister(2, 200) if err != ErrIllegalFunction { t.Errorf("c1.WriteRegister() should have failed with %v, got: %v", ErrIllegalFunction, err) } c2.SetUnitId(1) regs, err = c2.ReadRegisters(1, 2, HOLDING_REGISTER) if err != nil { t.Errorf("c2.ReadRegisters() should have succeeded, got: %v", err) } if regs[0] != 0 || regs[1] != 100 { t.Errorf("unexpected register values: %v", regs) } c2.SetUnitId(4) err = c2.WriteRegister(2, 200) if err != nil { t.Errorf("c2.WriteRegister() should have succeeded, got: %v", err) } regs, err = c2.ReadRegisters(1, 2, HOLDING_REGISTER) if err != nil { t.Errorf("c2.ReadRegisters() should have succeeded, got: %v", err) } if regs[0] != 0 || regs[1] != 200 { t.Errorf("unexpected register values: %v", regs) } // close the server and all client connections server.Stop() // make sure all underlying TCP client connections have been freed time.Sleep(10 * time.Millisecond) server.lock.Lock() if len(server.tcpClients) != 0 { t.Errorf("expected 0 client connections, saw: %v", len(server.tcpClients)) } server.lock.Unlock() // cleanup c1.Close() c2.Close() return } type tlsTestHandler struct { coils [10]bool holdingId1 [10]uint16 holdingId4 [10]uint16 } func (th *tlsTestHandler) HandleCoils(req *CoilsRequest) (res []bool, err error) { // coils access is allowed to any client with a valid cert, but // the "operator2" role is required to write if req.IsWrite && req.ClientRole != "operator2" { err = ErrIllegalFunction return } if req.Addr + req.Quantity > uint16(len(th.coils)) { err = ErrIllegalDataAddress return } for i := 0; i < int(req.Quantity); i++ { if req.IsWrite { th.coils[int(req.Addr) + i] = req.Args[i] } res = append(res, th.coils[int(req.Addr) + i]) } return } func (th *tlsTestHandler) HandleDiscreteInputs(req *DiscreteInputsRequest) (res []bool, err error) { // there are no digital inputs on this device err = ErrIllegalDataAddress return } func (th *tlsTestHandler) HandleHoldingRegisters(req *HoldingRegistersRequest) (res []uint16, err error) { // gate unit id #4 behind the "operator2" role while access to unit id #1 // is allowed to any valid cert if req.UnitId == 0x04 { if req.ClientRole != "operator2" { err = ErrIllegalFunction return } if req.Addr + req.Quantity > uint16(len(th.holdingId4)) { err = ErrIllegalDataAddress return } for i := 0; i < int(req.Quantity); i++ { if req.IsWrite { th.holdingId4[int(req.Addr) + i] = req.Args[i] } res = append(res, th.holdingId4[int(req.Addr) + i]) } } else if req.UnitId == 0x01 { if req.Addr + req.Quantity > uint16(len(th.holdingId1)) { err = ErrIllegalDataAddress return } for i := 0; i < int(req.Quantity); i++ { if req.IsWrite { th.holdingId1[int(req.Addr) + i] = req.Args[i] } res = append(res, th.holdingId1[int(req.Addr) + i]) } } else { err = ErrIllegalFunction return } return } func (th *tlsTestHandler) HandleInputRegisters(req *InputRegistersRequest) (res []uint16, err error) { // there are no inputs registers on this device err = ErrIllegalDataAddress return } func TestServerExtractRole(t *testing.T) { var ms *ModbusServer var pemBlock *pem.Block var x509Cert *x509.Certificate var err error var role string ms = &ModbusServer{ logger: newLogger("test-server-role-extraction", nil), } // load a client cert without role OID pemBlock, _ = pem.Decode([]byte(clientCert)) if err != nil { t.Errorf("failed to decode client cert: %v", err) return } x509Cert, err = x509.ParseCertificate(pemBlock.Bytes) if err != nil { t.Errorf("failed to parse client cert: %v", err) return } // calling extractRole on a cert without role extension should return an // empty string (see R-23 of the MBAPS spec) role = ms.extractRole(x509Cert) if role != "" { t.Errorf("role should have been empty, got: '%s'", role) } // load a certificate with a single role extension of "operator2" pemBlock, _ = pem.Decode([]byte(clientCertWithRoleOID)) if err != nil { t.Errorf("failed to decode client cert: %v", err) return } x509Cert, err = x509.ParseCertificate(pemBlock.Bytes) if err != nil { t.Errorf("failed to parse client cert: %v", err) return } role = ms.extractRole(x509Cert) if role != "operator2" { t.Errorf("role should have been 'operator2', got: '%s'", role) } // build a certificate with multiple Modbus Role extensions: they should // all be rejected x509Cert = &x509.Certificate{ Extensions: []pkix.Extension{ { Id: modbusRoleOID, Value: []byte{ 0x0c, 0x04, 0x66, 0x77, 0x67, 0x78, // ^ ASN1:UTF8String // ^ length // ^ 4-byte string 'fwgx' }, }, { Id: modbusRoleOID, Value: []byte{ 0x0c, 0x02, 0x66, 0x67, // ^ ASN1:UTF8String // ^ length // ^ 2-byte string 'fwwf' }, }, }, } role = ms.extractRole(x509Cert) if role != "" { t.Errorf("role should have been empty, got: '%s'", role) } // build a certificate with a single Modbus Role extension of the wrong // type: the role should be rejected x509Cert = &x509.Certificate{ Extensions: []pkix.Extension{ { Id: modbusRoleOID, Value: []byte{ 0x13, 0x04, 0x66, 0x77, 0x67, 0x78, // ^ ASN1:PrintableString // ^ length // ^ 4-byte string 'fwgx' }, }, }, } role = ms.extractRole(x509Cert) if role != "" { t.Errorf("role should have been empty, got: '%s'", role) } // build a certificate with a single, short Modbus Role extension: the role // should be rejected x509Cert = &x509.Certificate{ Extensions: []pkix.Extension{ { Id: modbusRoleOID, Value: []byte{ 0x0c, // ^ ASN1:UTF8String // ^ missing length + payload bytes }, }, }, } role = ms.extractRole(x509Cert) if role != "" { t.Errorf("role should have been empty, got: '%s'", role) } // build a certificate with one bad Modbus Role extension (short) and one // valid: they should both be rejected x509Cert = &x509.Certificate{ Extensions: []pkix.Extension{ { Id: modbusRoleOID, Value: []byte{ 0x0c, // ^ ASN1:UTF8String // ^ missing length + payload bytes }, }, { Id: modbusRoleOID, Value: []byte{ 0x0c, 0x02, 0x66, 0x67, // ^ ASN1:UTF8String // ^ length // ^ 2-byte string 'fwwf' }, }, }, } role = ms.extractRole(x509Cert) if role != "" { t.Errorf("role should have been empty, got: '%s'", role) } // build a certificate with a single, valid Modbus Role extension: it should be // accepted x509Cert = &x509.Certificate{ Extensions: []pkix.Extension{ { Id: modbusRoleOID, Value: []byte{ 0x0c, 0x04, 0x66, 0x77, 0x67, 0x78, // ^ ASN1:UTF8String // ^ length // ^ 4-byte string 'fwgx' }, }, }, } role = ms.extractRole(x509Cert) if role != "fwgx" { t.Errorf("role should have been 'fwgx', got: '%s'", role) } return }