From 96172bb008672d6ffc033cfbd6b02cbf3cd95ede Mon Sep 17 00:00:00 2001 From: Janik Knodel <10290002+led0nk@users.noreply.github.com> Date: Mon, 3 Feb 2025 11:54:22 +0100 Subject: [PATCH] mrand seeded private-key impl SignatureAlgorithm cmt changes --- go.mod | 6 +- go.sum | 12 +-- opcua_plugin/authentication.go | 80 ++++++++++++----- opcua_plugin/connect.go | 60 ++++++++----- opcua_plugin/generate_cert.go | 152 +++++++++++++++++++++++++++++++++ opcua_plugin/opcua.go | 34 +++++--- 6 files changed, 282 insertions(+), 62 deletions(-) diff --git a/go.mod b/go.mod index 880b1797..3aabee1e 100644 --- a/go.mod +++ b/go.mod @@ -314,7 +314,7 @@ require ( go.uber.org/zap v1.27.0 // indirect golang.org/x/mod v0.20.0 // indirect golang.org/x/oauth2 v0.22.0 // indirect - golang.org/x/term v0.27.0 // indirect + golang.org/x/term v0.28.0 // indirect golang.org/x/time v0.5.0 // indirect golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect google.golang.org/api v0.188.0 // indirect @@ -370,11 +370,11 @@ require ( github.com/youmark/pkcs8 v0.0.0-20201027041543-1326539a0a0a // indirect go.opentelemetry.io/otel v1.28.0 // indirect go.opentelemetry.io/otel/trace v1.28.0 // indirect - golang.org/x/crypto v0.31.0 // indirect + golang.org/x/crypto v0.32.0 // indirect golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa golang.org/x/net v0.33.0 // indirect golang.org/x/sync v0.10.0 // indirect - golang.org/x/sys v0.28.0 // indirect + golang.org/x/sys v0.29.0 // indirect golang.org/x/text v0.21.0 // indirect golang.org/x/tools v0.24.0 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect diff --git a/go.sum b/go.sum index 7c53a420..535ea0f2 100644 --- a/go.sum +++ b/go.sum @@ -1228,8 +1228,8 @@ golang.org/x/crypto v0.5.0/go.mod h1:NK/OQwhpMQP3MwtdjgLlYHnH9ebylxKWv3e0fK+mkQU golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.20.0/go.mod h1:Xwo95rrVNIoSMx9wa1JroENMToLWn3RNVrTBpLHgZPQ= -golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= -golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= +golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -1385,8 +1385,8 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= -golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -1395,8 +1395,8 @@ golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= -golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= -golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= +golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg= +golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/opcua_plugin/authentication.go b/opcua_plugin/authentication.go index 4c6fc3b5..24402221 100644 --- a/opcua_plugin/authentication.go +++ b/opcua_plugin/authentication.go @@ -16,26 +16,43 @@ func (g *OPCUAInput) orderEndpoints( selectedAuthentication ua.UserTokenType, ) []*ua.EndpointDescription { - var highSecurityEndpoints, mediumSecurityEndpoints, lowSecurityEndpoints, noSecurityEndpoints []*ua.EndpointDescription + var ( + signAndEncryptBasic256Sha256Endpoints []*ua.EndpointDescription + signBasic256Sha256Endpoints []*ua.EndpointDescription + signAndEncryptBasic256Endpoints []*ua.EndpointDescription + signBasic256Endpoints []*ua.EndpointDescription + signAndEncryptBasic128Rsa15Endpoints []*ua.EndpointDescription + signBasic128Rsa15Endpoints []*ua.EndpointDescription + noSecurityEndpoints []*ua.EndpointDescription + ) for _, endpoint := range endpoints { if isUserTokenSupported(endpoint, selectedAuthentication) { switch { - case isSignAndEncryptbasic256Sha256Endpoint(endpoint): - highSecurityEndpoints = append(highSecurityEndpoints, endpoint) - case isSignAndEncryptbasic256Endpoint(endpoint): - mediumSecurityEndpoints = append(mediumSecurityEndpoints, endpoint) - case isSignAndEncryptbasic128Rsa15Endpoint(endpoint): - lowSecurityEndpoints = append(lowSecurityEndpoints, endpoint) + case isSignAndEncryptBasic256Sha256Endpoint(endpoint): + signAndEncryptBasic256Sha256Endpoints = append(signAndEncryptBasic256Sha256Endpoints, endpoint) + case isSignBasic256Sha256Endpoint(endpoint): + signBasic256Sha256Endpoints = append(signBasic256Sha256Endpoints, endpoint) + case isSignAndEncryptBasic256Endpoint(endpoint): + signAndEncryptBasic256Endpoints = append(signAndEncryptBasic256Endpoints, endpoint) + case isSignBasic256Endpoint(endpoint): + signBasic256Endpoints = append(signBasic256Endpoints, endpoint) + case isSignAndEncryptBasic128Rsa15Endpoint(endpoint): + signAndEncryptBasic128Rsa15Endpoints = append(signAndEncryptBasic128Rsa15Endpoints, endpoint) + case isSignBasic128Rsa15Endpoint(endpoint): + signBasic128Rsa15Endpoints = append(signBasic128Rsa15Endpoints, endpoint) case isNoSecurityEndpoint(endpoint): noSecurityEndpoints = append(noSecurityEndpoints, endpoint) } } } - // Append no security endpoints to the end of the high security endpoints. - orderedEndpoints := append(highSecurityEndpoints, mediumSecurityEndpoints...) - orderedEndpoints = append(orderedEndpoints, lowSecurityEndpoints...) + // Append medium security endpoints to the end of the high security endpoints. + orderedEndpoints := append(signAndEncryptBasic256Sha256Endpoints, signBasic256Sha256Endpoints...) + orderedEndpoints = append(orderedEndpoints, signAndEncryptBasic256Endpoints...) + orderedEndpoints = append(orderedEndpoints, signAndEncryptBasic128Rsa15Endpoints...) + orderedEndpoints = append(orderedEndpoints, signBasic256Endpoints...) + orderedEndpoints = append(orderedEndpoints, signBasic128Rsa15Endpoints...) orderedEndpoints = append(orderedEndpoints, noSecurityEndpoints...) return orderedEndpoints @@ -51,26 +68,41 @@ func isUserTokenSupported(endpoint *ua.EndpointDescription, selectedAuth ua.User return false } -// isSignAndEncryptbasic256Sha256Endpoint checks if the endpoint is configured with SignAndEncrypt and Basic256Sha256 security. -func isSignAndEncryptbasic256Sha256Endpoint(endpoint *ua.EndpointDescription) bool { - return endpoint.SecurityMode == ua.MessageSecurityModeFromString("SignAndEncrypt") && - endpoint.SecurityPolicyURI == "http://opcfoundation.org/UA/SecurityPolicy#Basic256Sha256" +// isSignAndEncryptBasic256Sha256Endpoint checks if the endpoint is configured with SignAndEncrypt and Basic256Sha256 security. +func isSignAndEncryptBasic256Sha256Endpoint(endpoint *ua.EndpointDescription) bool { + return endpoint.SecurityMode == ua.MessageSecurityModeSignAndEncrypt && + endpoint.SecurityPolicyURI == ua.SecurityPolicyURIBasic256Sha256 } -func isSignAndEncryptbasic256Endpoint(endpoint *ua.EndpointDescription) bool { - return endpoint.SecurityMode == ua.MessageSecurityModeFromString("SignAndEncrypt") && - endpoint.SecurityPolicyURI == "http://opcfoundation.org/UA/SecurityPolicy#Basic256" +func isSignBasic256Sha256Endpoint(endpoint *ua.EndpointDescription) bool { + return endpoint.SecurityMode == ua.MessageSecurityModeSign && + endpoint.SecurityPolicyURI == ua.SecurityPolicyURIBasic256Sha256 } -func isSignAndEncryptbasic128Rsa15Endpoint(endpoint *ua.EndpointDescription) bool { - return endpoint.SecurityMode == ua.MessageSecurityModeFromString("SignAndEncrypt") && - endpoint.SecurityPolicyURI == "http://opcfoundation.org/UA/SecurityPolicy#Basic128Rsa15" +func isSignAndEncryptBasic256Endpoint(endpoint *ua.EndpointDescription) bool { + return endpoint.SecurityMode == ua.MessageSecurityModeSignAndEncrypt && + endpoint.SecurityPolicyURI == ua.SecurityPolicyURIBasic256 +} + +func isSignBasic256Endpoint(endpoint *ua.EndpointDescription) bool { + return endpoint.SecurityMode == ua.MessageSecurityModeSign && + endpoint.SecurityPolicyURI == ua.SecurityPolicyURIBasic256 +} + +func isSignAndEncryptBasic128Rsa15Endpoint(endpoint *ua.EndpointDescription) bool { + return endpoint.SecurityMode == ua.MessageSecurityModeSignAndEncrypt && + endpoint.SecurityPolicyURI == ua.SecurityPolicyURIBasic128Rsa15 +} + +func isSignBasic128Rsa15Endpoint(endpoint *ua.EndpointDescription) bool { + return endpoint.SecurityMode == ua.MessageSecurityModeSign && + endpoint.SecurityPolicyURI == ua.SecurityPolicyURIBasic128Rsa15 } // isNoSecurityEndpoint checks if the endpoint has no security configured. func isNoSecurityEndpoint(endpoint *ua.EndpointDescription) bool { - return endpoint.SecurityMode == ua.MessageSecurityModeFromString("None") && - endpoint.SecurityPolicyURI == "http://opcfoundation.org/UA/SecurityPolicy#None" + return endpoint.SecurityMode == ua.MessageSecurityModeNone && + endpoint.SecurityPolicyURI == ua.SecurityPolicyURINone } // getEndpointIfExists searches within the provided endpoints for a suitable OPC UA endpoint. @@ -94,7 +126,9 @@ func (g *OPCUAInput) getEndpointIfExists( for _, userIdentity := range endpoint.UserIdentityTokens { // Match the endpoint with the selected authentication type. - if selectedAuthentication == userIdentity.TokenType && endpoint.SecurityPolicyURI == "http://opcfoundation.org/UA/SecurityPolicy#"+securityPolicy && endpoint.SecurityMode == ua.MessageSecurityModeFromString(securityMode) { + if selectedAuthentication == userIdentity.TokenType && + endpoint.SecurityPolicyURI == ua.FormatSecurityPolicyURI(securityPolicy) && + endpoint.SecurityMode == ua.MessageSecurityModeFromString(securityMode) { return endpoint, nil } diff --git a/opcua_plugin/connect.go b/opcua_plugin/connect.go index f2825061..1f354931 100644 --- a/opcua_plugin/connect.go +++ b/opcua_plugin/connect.go @@ -24,7 +24,7 @@ import ( // **Why This Function is Needed:** // - To dynamically generate client options that match the server’s security requirements. // - To handle different authentication methods, such as anonymous or username/password-based logins. -// - To generate and include client certificates when using enhanced security policies like Basic256Sha256. +// - To generate and include client certificates when using enhanced security policies like Basic256Sha256/Basic256/Basic128Rsa15 func (g *OPCUAInput) GetOPCUAClientOptions(selectedEndpoint *ua.EndpointDescription, selectedAuthentication ua.UserTokenType) (opts []opcua.Option, err error) { opts = append(opts, opcua.SecurityFromEndpoint(selectedEndpoint, selectedAuthentication)) @@ -37,31 +37,53 @@ func (g *OPCUAInput) GetOPCUAClientOptions(selectedEndpoint *ua.EndpointDescript opts = append(opts, opcua.AuthUsername(g.Username, g.Password)) } - // Generate certificates if Basic256Sha256 - if selectedEndpoint.SecurityPolicyURI == ua.SecurityPolicyURIBasic256Sha256 { - randomStr := randomString(8) // Generates an 8-character random string - clientName := "urn:benthos-umh-test:client-" + randomStr - certPEM, keyPEM, err := GenerateCert(clientName, 2048, 24*time.Hour*365*10) - if err != nil { - g.Log.Errorf("Failed to generate certificate: %v", err) - return nil, err - } + // Generate certificates if we don't connect without Security + if selectedEndpoint.SecurityPolicyURI != ua.SecurityPolicyURINone { + if g.cachedTLSCertificate == nil { + if g.CertificateSeed == "" { + // Generate an 8-character random string if no 'certificateSeed' + // provided by the user. + g.CertificateSeed = randomString(8) + } - // Convert PEM to X509 Certificate and RSA PrivateKey for in-memory use. - cert, err := tls.X509KeyPair(certPEM, keyPEM) - if err != nil { - g.Log.Errorf("Failed to parse certificate: %v", err) - return nil, err + // Just use a random String to make the appearance in "trusted clients" + // more "unique". So the user is able to recognize the client in the + // servers UI. + clientNameUID := randomString(8) + + clientName := "urn:benthos-umh:client-predefined-" + clientNameUID + certPEM, keyPEM, err := GenerateCertWithMode(clientName, + 24*time.Hour*365*10, + g.SecurityMode, + g.SecurityPolicy, + g.CertificateSeed, + clientNameUID) + if err != nil { + g.Log.Errorf("Failed to generate certificate: %v", err) + return nil, err + } + + // Convert PEM to X509 Certificate and RSA PrivateKey for in-memory use. + cert, err := tls.X509KeyPair(certPEM, keyPEM) + if err != nil { + g.Log.Errorf("Failed to parse certificate: %v", err) + return nil, err + } + // cache the tls.Certificate for future calls + g.cachedTLSCertificate = &cert + g.Log.Infof("The clients certificate was created, to use an encrypted connection "+ + "please proceed to the OPC-UA Server Configuration and trust either all clients "+ + "or the clients certificate with the Application-URI: '%s", clientName) } - pk, ok := cert.PrivateKey.(*rsa.PrivateKey) + pk, ok := g.cachedTLSCertificate.PrivateKey.(*rsa.PrivateKey) if !ok { g.Log.Errorf("Invalid private key type") return nil, err } // Append the certificate and private key to the client options - opts = append(opts, opcua.PrivateKey(pk), opcua.Certificate(cert.Certificate[0])) + opts = append(opts, opcua.PrivateKey(pk), opcua.Certificate(g.cachedTLSCertificate.Certificate[0])) } opts = append(opts, opcua.SessionName("benthos-umh")) @@ -70,7 +92,7 @@ func (g *OPCUAInput) GetOPCUAClientOptions(selectedEndpoint *ua.EndpointDescript } else { opts = append(opts, opcua.SessionTimeout(SessionTimeout)) } - opts = append(opts, opcua.ApplicationName("benthos-umh")) + opts = append(opts, opcua.ApplicationName("benthos-umh-predefined")) //opts = append(opts, opcua.ApplicationURI("urn:benthos-umh")) //opts = append(opts, opcua.ProductURI("urn:benthos-umh")) @@ -533,7 +555,7 @@ func (g *OPCUAInput) connect(ctx context.Context) error { if g.SecurityMode != "" && g.SecurityPolicy != "" { c, err = g.connectWithSecurity(ctx, endpoints, selectedAuthentication) if err != nil { - g.Log.Infof("error while connecting using securitymode %s, securityPolicy: %s. err:%v", g.SecurityMode, g.SecurityPolicy, err) + g.Log.Infof("error while connecting using securitymode '%s', securityPolicy: '%s'. err:%v", g.SecurityMode, g.SecurityPolicy, err) return err } diff --git a/opcua_plugin/generate_cert.go b/opcua_plugin/generate_cert.go index 7e6bfe9d..1feae1fc 100644 --- a/opcua_plugin/generate_cert.go +++ b/opcua_plugin/generate_cert.go @@ -18,7 +18,10 @@ import ( "crypto/x509/pkix" "encoding/pem" "fmt" + "hash/fnv" + "io" "math/big" + mrand "math/rand" "net" "net/url" "os" @@ -26,6 +29,155 @@ import ( "time" ) +type seededReader struct { + src *mrand.Rand +} + +func newSeededReader(seed int64) io.Reader { + return &seededReader{src: mrand.New(mrand.NewSource(seed))} +} + +func (r *seededReader) Read(p []byte) (int, error) { + for i := range p { + p[i] = byte(r.src.Intn(256)) + } + return len(p), nil +} + +// GenerateCertWithMode generates a self-signed X.509 certificate for OPC UA +// usage, taking into account the security mode (Sign vs SignAndEncrypt) and +// the desired security policy (Basic128Rsa15, Basic256, Basic256Sha256, etc.). +// +// - host: CommonName & DNS/IP/URI entries to include +// - validFor: Certificate validity duration +// - securityMode: The OPC UA message security mode (None, Sign, SignAndEncrypt) +// - policy: The OPC UA security policy (e.g., "Basic128Rsa15", "Basic256", "Basic256Sha256") +func GenerateCertWithMode( + host string, + validFor time.Duration, + securityMode string, + securityPolicy string, + seedString string, + clientNameUID string, +) (certPEM, keyPEM []byte, err error) { + var rsaBits int + + if len(host) == 0 { + return nil, nil, fmt.Errorf("missing required host parameter") + } + var signatureAlgorithm x509.SignatureAlgorithm + + switch securityPolicy { + case "Basic256Rsa256": + // typically a 2048-bit RSA key + rsaBits = 2048 + signatureAlgorithm = x509.SHA256WithRSA + case "Basic256": + // typically a 2048-bit RSA key + rsaBits = 2048 + signatureAlgorithm = x509.SHA1WithRSA + case "Basic128Rsa15": + // typically a 1024-bit RSA key, sometimes 2048-bit keys also work + rsaBits = 1024 + signatureAlgorithm = x509.SHA1WithRSA + default: + // fallback, we could also err out here if we don't want to allow + // something else + rsaBits = 2048 + signatureAlgorithm = x509.SHA256WithRSA + } + + h := fnv.New64a() + h.Write([]byte(seedString)) + seed := int64(h.Sum64()) + + // Create a custom io.Reader to ensure we don't use random Numbers to create + // the private key, but instead use the 'certificateSeed'. + seededReader := newSeededReader(seed) + + // Generate RSA private key + priv, err := rsa.GenerateKey(seededReader, rsaBits) + if err != nil { + return nil, nil, fmt.Errorf("failed to generate private key: %w", err) + } + + notBefore := time.Now() + notAfter := notBefore.Add(validFor) + + // Use 127 bits for the serial to ensure positivity in ASN.1 + serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 127) + serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) + if err != nil { + return nil, nil, fmt.Errorf("failed to generate serial number: %w", err) + } + + // Prepare the certificate template + template := x509.Certificate{ + SerialNumber: serialNumber, + Subject: pkix.Name{ + // Modify as appropriate for your org + CommonName: "benthos-umh-predefined-" + clientNameUID, + Organization: []string{"UMH"}, + }, + NotBefore: notBefore, + NotAfter: notAfter, + BasicConstraintsValid: true, + SignatureAlgorithm: signatureAlgorithm, + + // ExtKeyUsage: Both server & client auth for OPC UA usage + ExtKeyUsage: []x509.ExtKeyUsage{ + x509.ExtKeyUsageServerAuth, + x509.ExtKeyUsageClientAuth, + }, + } + + // Fill in IPAddresses, DNSNames, URIs from the host string (comma-separated) + hosts := strings.Split(host, ",") + for _, h := range hosts { + if ip := net.ParseIP(h); ip != nil { + template.IPAddresses = append(template.IPAddresses, ip) + } else { + template.DNSNames = append(template.DNSNames, h) + } + if uri, parseErr := url.Parse(h); parseErr == nil && (uri.Scheme == "urn" || uri.Scheme == "http" || uri.Scheme == "https") { + template.URIs = append(template.URIs, uri) + } + } + + // Decide on the key usage bits based on security mode + switch securityMode { + case "Sign": + // For Sign-only, we need DigitalSignature + template.KeyUsage = x509.KeyUsageDigitalSignature + case "SignAndEncrypt": + // For Sign and Encrypt, we need KeyEncipherment + DigitalSignature + template.KeyUsage = x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature | x509.KeyUsageDataEncipherment + default: + // e.g. fallback for SecurityMode 'None' + template.KeyUsage = x509.KeyUsageDigitalSignature + } + + // Actually create the certificate + derBytes, err := x509.CreateCertificate( + //NOTE: We could also seed the certificate itself, but at least 1 component + // should stay random, either the certificate or the priv.key + rand.Reader, + &template, + &template, // self-signed + publicKey(priv), + priv, + ) + if err != nil { + return nil, nil, fmt.Errorf("failed to create certificate: %w", err) + } + + // PEM-encode the results + certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes}) + keyPEM = pem.EncodeToMemory(pemBlockForKey(priv)) + + return certPEM, keyPEM, nil +} + func GenerateCert(host string, rsaBits int, validFor time.Duration) (certPEM, keyPEM []byte, err error) { if len(host) == 0 { return nil, nil, fmt.Errorf("missing required host parameter") diff --git a/opcua_plugin/opcua.go b/opcua_plugin/opcua.go index 37f21cbe..af8aa3e4 100644 --- a/opcua_plugin/opcua.go +++ b/opcua_plugin/opcua.go @@ -16,6 +16,7 @@ package opcua_plugin import ( "context" + "crypto/tls" "sync" "sync/atomic" "time" @@ -46,7 +47,8 @@ var OPCUAConfigSpec = service.NewConfigSpec(). Field(service.NewBoolField("useHeartbeat").Description("Set to true to provide an extra message with the servers timestamp as a heartbeat").Default(false)). Field(service.NewIntField("pollRate").Description("The rate in milliseconds at which to poll the OPC UA server when not using subscriptions. Defaults to 1000ms (1 second).").Default(DefaultPollRate)). Field(service.NewBoolField("autoReconnect").Description("Set to true to automatically reconnect to the OPC UA server when the connection is lost. Defaults to 'false'").Default(false)). - Field(service.NewIntField("reconnectIntervalInSeconds").Description("The interval in seconds at which to reconnect to the OPC UA server when the connection is lost. This is only used if `autoReconnect` is set to true. Defaults to 5 seconds.").Default(5)) + Field(service.NewIntField("reconnectIntervalInSeconds").Description("The interval in seconds at which to reconnect to the OPC UA server when the connection is lost. This is only used if `autoReconnect` is set to true. Defaults to 5 seconds.").Default(5)). + Field(service.NewStringField("certificateSeed").Description("The certificate seed is a secret string provided by the user, which will then get hashed to create the clients certificate. This is needed to ensure an explicit trusted client, which is significant for an encrypted connection. If not set, this will be provided by a random created string.").Default("")) func ParseNodeIDs(incomingNodes []string) []*ua.NodeID { @@ -137,6 +139,11 @@ func newOPCUAInput(conf *service.ParsedConfig, mgr *service.Resources) (service. return nil, err } + certificateSeed, err := conf.FieldString("certificateSeed") + if err != nil { + return nil, err + } + // fail if no nodeIDs are provided if len(nodeIDs) == 0 { return nil, errors.New("no nodeIDs provided") @@ -152,6 +159,7 @@ func newOPCUAInput(conf *service.ParsedConfig, mgr *service.Resources) (service. Log: mgr.Logger(), SecurityMode: securityMode, SecurityPolicy: securityPolicy, + CertificateSeed: certificateSeed, Insecure: insecure, SubscribeEnabled: subscribeEnabled, SessionTimeout: sessionTimeout, @@ -185,16 +193,17 @@ func init() { } type OPCUAInput struct { - Endpoint string - Username string - Password string - NodeIDs []*ua.NodeID - NodeList []NodeDef - SecurityMode string - SecurityPolicy string - Insecure bool - Client *opcua.Client - Log *service.Logger + Endpoint string + Username string + Password string + NodeIDs []*ua.NodeID + NodeList []NodeDef + SecurityMode string + SecurityPolicy string + CertificateSeed string + Insecure bool + Client *opcua.Client + Log *service.Logger // this is required for subscription SubscribeEnabled bool SubNotifyChan chan *opcua.PublishNotificationData @@ -214,6 +223,9 @@ type OPCUAInput struct { AutoReconnect bool ReconnectIntervalInSeconds int visited sync.Map + cachedCert []byte // DER bytes or PEM + cachedKey []byte // DER bytes or PEM + cachedTLSCertificate *tls.Certificate // certificate } // cleanupBrowsing ensures the browsing goroutine is properly stopped and cleaned up