Skip to content

Commit

Permalink
allow using self-signed certs with pure-ip hosts (francoismichel#14)
Browse files Browse the repository at this point in the history
Until now, servers with no domain name attached could not use self-signed certificate without specifying its IP address in the Subject Alternative Names certificate extension, otherwise the client would not accept the connection. This means that it could not use the generate_openssl_selfsigned_certificate.sh script.

Now, when (and only when) connecting to a host without domain name (i.e. a pure-IP host), the client puts selfsigned.ssh3 in the TLS ServerName option. If the server self-signed cert installed in .ssh3/known_hosts matches that, then the connection can be established with the server without domain name.

* handle special case for certs with no IP SANs

* only generate selfsigned certs for selfsigned.ssh3 in generate_openssl_selfsigned_certificate.sh

* client: make clearer message when the host self-signed certificate has changed
  • Loading branch information
francoismichel authored Dec 8, 2023
1 parent 003bd9f commit d25d5da
Show file tree
Hide file tree
Showing 3 changed files with 96 additions and 10 deletions.
41 changes: 32 additions & 9 deletions client/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,8 @@ func mainWithStatusCode() int {
hostname = urlHostname
}

hostnameIsAnIP := net.ParseIP(hostname) != nil

port := configPort
if port == -1 && urlPort != "" {
port, err = strconv.Atoi(urlPort)
Expand Down Expand Up @@ -470,9 +472,34 @@ func mainWithStatusCode() int {
log.Fatal().Msgf("%s", err)
}


tlsConf := &tls.Config{
RootCAs: pool,
InsecureSkipVerify: *insecure,
KeyLogWriter: keyLog,
NextProtos: []string{http3.NextProtoH3},
}

if certs, ok := knownHosts[hostname]; ok {
foundAnIPSAN := false
for _, cert := range certs {
hasIPSAN, err := util.CertHasIPSANs(cert)
if err != nil {
log.Warn().Msgf("could not parse known_hosts certificate for hostname %s", hostname)
continue
}
pool.AddCert(cert)
if hasIPSAN {
foundAnIPSAN = true
}
}

// If no IP SAN was in the cert, then assume the self-signed cert at least matches the .ssh3 TLD
if hostnameIsAnIP && !foundAnIPSAN {
// Put "ssh3" as ServerName so that the TLS verification can succeed
// Otherwise, TLS refuses to validate a certificate without IP SANs
// if the hostname is an IP address.
tlsConf.ServerName = "selfsigned.ssh3"
}
}

Expand All @@ -481,15 +508,8 @@ func mainWithStatusCode() int {
qconf.MaxIncomingStreams = 10
qconf.Allow0RTT = true
qconf.EnableDatagrams = true

tlsConf := &tls.Config{
RootCAs: pool,
InsecureSkipVerify: *insecure,
KeyLogWriter: keyLog,
NextProtos: []string{http3.NextProtoH3},
}

qconf.KeepAlivePeriod = 1 * time.Second

roundTripper := &http3.RoundTripper{
TLSClientConfig: tlsConf,
QuicConfig: &qconf,
Expand Down Expand Up @@ -538,7 +558,10 @@ func mainWithStatusCode() int {
return -1
}
if _, ok = knownHosts[hostname]; ok {
log.Error().Msgf("could not establish QUIC connection with a server already listed in %s: %s", knownHostsPath, err)
log.Error().Msgf("The server certificate cannot be verified using the one installed in %s. " +
"If you did not change the server certificate, it could be a machine-in-the-middle attack. "+
"TLS error: %s", knownHostsPath, err)
log.Error().Msgf("Aborting.")
return -1
}
// bad certificates, let's mimic the OpenSSH's behaviour similar to host keys
Expand Down
2 changes: 1 addition & 1 deletion generate_openssl_selfsigned_certificate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@
# however, if you want a comparable security level to OpenSSH's host keys,
# you can use this script to generate a self-signed certificate for every host
# and every IP address to install on your server
openssl req -x509 -sha256 -nodes -newkey rsa:4096 -keyout priv.key -days 3660 -out cert.pem -subj "/C=XX/O=Default Company/OU=XX/CN=*" -addext "subjectAltName = DNS:*"
openssl req -x509 -sha256 -nodes -newkey rsa:4096 -keyout priv.key -days 3660 -out cert.pem -subj "/C=XX/O=Default Company/OU=XX/CN=selfsigned.ssh3" -addext "subjectAltName = DNS:selfsigned.ssh3"
63 changes: 63 additions & 0 deletions src/util/util.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@ import (
"crypto/ed25519"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"errors"
"fmt"
"net"
"os"
"os/exec"
"strings"
Expand All @@ -17,6 +20,8 @@ import (
ptylib "github.com/creack/pty"
"github.com/golang-jwt/jwt/v5"
"github.com/rs/zerolog"
"golang.org/x/crypto/cryptobyte"
cryptobyte_asn1 "golang.org/x/crypto/cryptobyte/asn1"
)

type UnknownSSHPubkeyType struct {
Expand Down Expand Up @@ -222,4 +227,62 @@ func JWTSigningMethodFromCryptoPubkey(pubkey crypto.PublicKey) (jwt.SigningMetho
func Sha256Fingerprint(in []byte) string {
hash := sha256.Sum256(in)
return base64.StdEncoding.EncodeToString(hash[:])
}


func getSANExtension(cert *x509.Certificate) []byte {
oidExtensionSubjectAltName := []int{2, 5, 29, 17}
for _, e := range cert.Extensions {
if e.Id.Equal(oidExtensionSubjectAltName) {
return e.Value
}
}
return nil
}


func forEachSAN(der cryptobyte.String, callback func(tag int, data []byte) error) error {
if !der.ReadASN1(&der, cryptobyte_asn1.SEQUENCE) {
return errors.New("x509: invalid subject alternative names")
}
for !der.Empty() {
var san cryptobyte.String
var tag cryptobyte_asn1.Tag
if !der.ReadAnyASN1(&san, &tag) {
return errors.New("x509: invalid subject alternative name")
}
if err := callback(int(tag^0x80), san); err != nil {
return err
}
}

return nil
}


// returns true whether the certificat contains a SubjectAltName extension
// with at least one IP address record
func CertHasIPSANs(cert *x509.Certificate) (bool, error) {
SANExtension := getSANExtension(cert)
if SANExtension == nil {
return false, nil
}
nameTypeIP := 7
var ipAddresses []net.IP

err := forEachSAN(SANExtension, func(tag int, data []byte) error {
switch tag {
case nameTypeIP:
switch len(data) {
case net.IPv4len, net.IPv6len:
ipAddresses = append(ipAddresses, data)
default:
return fmt.Errorf("x509: cannot parse IP address of length %d",len(data))
}
default:
}

return nil
})
return len(ipAddresses) > 0, err
}

0 comments on commit d25d5da

Please sign in to comment.