Featured image of post Mutual TLS (mTLS) Go client using macOS keychain

Mutual TLS (mTLS) Go client using macOS keychain

How to build an mTLS Go client that uses Apple's macOS keychain

This article is part of a series on mTLS. Check out the previous articles:

Why use macOS keychain?

In the mTLS Go client article, we built a simple Go client that uses mTLS. Our client used Go standard library methods and loaded the client certificate and private key from the filesystem. However, keeping the private key on the filesystem is insecure and not recommended. We aim to build an mTLS client fully integrated with the operating system’s keystore.

The macOS keychain is a secure storage system for passwords and other confidential information. It is used by many Apple applications, such as Safari, Mail, and iCloud, to store the user’s passwords and additional sensitive information.

Building a custom tls.Certificate for macOS keychain

This work builds on the mTLS Go client with custom certificate signer article. We will use the CustomSigner from that article to build a custom tls.Certificate that uses the macOS keychain.

However, before the application uses the Public and Sign methods of the CustomSigner, we need to retrieve the certificate from the keychain using Apple’s API.

Retrieving certificate from macOS keychain with CGO

We will use CGO to call the macOS keychain API to retrieve the client certificate. To set up CGO, we include the following code above our imports:

/*
   #cgo LDFLAGS: -framework CoreFoundation -framework Security
   #include <CoreFoundation/CoreFoundation.h>
   #include <Security/Security.h>
*/
import "C"

To find the identities from the keychain, we use SecItemCopyMatching. An identity is a certificate and its associated private key.

identitySearch := C.CFDictionaryCreateMutable(
    C.kCFAllocatorDefault, maxCertificatesNum, &C.kCFTypeDictionaryKeyCallBacks,
     &C.kCFTypeDictionaryValueCallBacks,
)
defer C.CFRelease(C.CFTypeRef(unsafe.Pointer(identitySearch)))
const commonName = "testClientTLS"
var commonNameCFString = stringToCFString(commonName)
defer C.CFRelease(C.CFTypeRef(commonNameCFString))
C.CFDictionaryAddValue(identitySearch, unsafe.Pointer(C.kSecClass),
    unsafe.Pointer(C.kSecClassIdentity))
C.CFDictionaryAddValue(identitySearch, unsafe.Pointer(C.kSecAttrCanSign),
    unsafe.Pointer(C.kCFBooleanTrue))
C.CFDictionaryAddValue(identitySearch, unsafe.Pointer(C.kSecMatchSubjectWholeString),
    unsafe.Pointer(commonNameCFString))
// To filter by issuers, we must provide a CFDataRef array of DER-encoded ASN.1 items.
// C.CFDictionaryAddValue(identitySearch, unsafe.Pointer(C.kSecMatchIssuers), unsafe.Pointer(issuerCFArray))
C.CFDictionaryAddValue(identitySearch, unsafe.Pointer(C.kSecReturnRef),
    unsafe.Pointer(C.kCFBooleanTrue))
C.CFDictionaryAddValue(identitySearch, unsafe.Pointer(C.kSecMatchLimit),
    unsafe.Pointer(C.kSecMatchLimitAll))
var identityMatches C.CFTypeRef
if status := C.SecItemCopyMatching(C.CFDictionaryRef(identitySearch), &identityMatches);
    status != C.errSecSuccess {
    return nil, fmt.Errorf("failed to find client certificate: %v", status)
}
defer C.CFRelease(identityMatches)

In our example, we find the identities by a common name, which we hardcode for demonstration purposes. We can filter by the certificate issuer, as shown in the commented-out code. Filtering by issuer requires an array of DER-encoded ASN.1 items, which can be created from the tls.CertificateRequestInfo object. Another approach to finding the proper certificate is to retrieve all the keychain certificates and filter them in Go code.

Converting the Apple identity to a Go x509.Certificate

After we retrieve the array of identities from the keychain, we convert them to Go x509.Certificate objects and pick the first one that is not expired.

var foundCert *x509.Certificate
var foundIdentity C.SecIdentityRef
identityMatchesArrayRef := C.CFArrayRef(identityMatches)
numIdentities := int(C.CFArrayGetCount(identityMatchesArrayRef))
fmt.Printf("Found %d identities\n", numIdentities)
for i := 0; i < numIdentities; i++ {
    identityMatch := C.CFArrayGetValueAtIndex(identityMatchesArrayRef, C.CFIndex(i))
    x509Cert, err := identityRefToCert(C.SecIdentityRef(identityMatch))
    if err != nil {
        continue
    }
    // Make sure certificate is not expired
    if x509Cert.NotAfter.After(time.Now()) {
        foundCert = x509Cert
        foundIdentity = C.SecIdentityRef(identityMatch)
        fmt.Printf("Found certificate from issuer %s with public key type %T\n",
            x509Cert.Issuer.String(), x509Cert.PublicKey)
        break
    }
}

The identityRefToCert function converts the SecIdentityRef to a Go x509.Certificate object. It exports the certificate to PEM format using SecItemExport and then parses the PEM to get the x509.Certificate object.

func identityRefToCert(identityRef C.SecIdentityRef) (*x509.Certificate, error) {
    // Convert the identity to a certificate
    var certificateRef C.SecCertificateRef
    if status := C.SecIdentityCopyCertificate(identityRef, &certificateRef);
        status != 0 {
       return nil, fmt.Errorf("failed to get certificate from identity: %v", status)
    }
    defer C.CFRelease(C.CFTypeRef(certificateRef))

    // Export the certificate to PEM
    // SecItemExport: https://developer.apple.com/documentation/security/1394828-secitemexport
    var pemDataRef C.CFDataRef
    if status := C.SecItemExport(
       C.CFTypeRef(certificateRef), C.kSecFormatPEMSequence, C.kSecItemPemArmour, nil,
           &pemDataRef,
    ); status != 0 {
       return nil, fmt.Errorf("failed to export certificate to PEM: %v", status)
    }
    defer C.CFRelease(C.CFTypeRef(pemDataRef))
    certPEM := C.GoBytes(unsafe.Pointer(C.CFDataGetBytePtr(pemDataRef)),
        C.int(C.CFDataGetLength(pemDataRef)))

    var x509Cert *x509.Certificate
    for block, rest := pem.Decode(certPEM); block != nil; block, rest = pem.Decode(rest) {
       if block.Type == "CERTIFICATE" {
          var err error
          x509Cert, err = x509.ParseCertificate(block.Bytes)
          if err != nil {
             return nil, fmt.Errorf("error parsing client certificate: %v", err)
          }
       }
    }
    return x509Cert, nil
}

Retrieve the private key reference from the keychain

At this point, we also retrieve the private key reference from the keychain. We will use the private key reference to sign the CertificateVerify message during the TLS handshake. The reference does not contain the private key. When importing private keys to the keychain, they should be marked as non-exportable so that no one can retrieve the private key cleartext from the keychain.

var privateKey C.SecKeyRef
if status := C.SecIdentityCopyPrivateKey(C.SecIdentityRef(foundIdentity), &privateKey);
    status != 0 {
    return nil, fmt.Errorf("failed to copy private key ref from identity: %v", status)
}

Building the custom tls.Certificate

Finally, we put together the custom tls.Certificate using the x509.Certificate and the private key reference.

customSigner := &CustomSigner{
    x509Cert:   foundCert,
    privateKey: privateKey,
}
certificate := tls.Certificate{
    Certificate:                  [][]byte{foundCert.Raw},
    PrivateKey:                   customSigner,
    SupportedSignatureAlgorithms: []tls.SignatureScheme{supportedAlgorithm},
}

Our example only supports the tls.PSSWithSHA256 signature algorithm to keep the code simple. Adding additional algorithm support is easy since it only requires passing the right parameter to the SecKeyCreateSignature function, which we will review next.

Signing the mTLS digest with Apple’s keychain

As discussed in the previous mTLS Go client with custom certificate signer article, we need to sign the CertificateVerify message during the TLS handshake. We will use the CustomSigner to sign the digest, which implements the crypto.Signer interface as defined in the Go standard library’s crypto package.

type CustomSigner struct {
    x509Cert   *x509.Certificate
    privateKey C.SecKeyRef
}

func (k *CustomSigner) Public() crypto.PublicKey {
    fmt.Printf("crypto.Signer.Public\n")
    return k.x509Cert.PublicKey
}

func (k *CustomSigner) Sign(_ io.Reader, digest []byte, opts crypto.SignerOpts) (
    signature []byte, err error) {
    fmt.Printf("crypto.Signer.Sign with key type %T, opts type %T, hash %s\n",
        k.Public(), opts, opts.HashFunc().String())

    // Convert the digest to a CFDataRef
    digestCFData := C.CFDataCreate(C.kCFAllocatorDefault,
        (*C.UInt8)(unsafe.Pointer(&digest[0])), C.CFIndex(len(digest)))
    defer C.CFRelease(C.CFTypeRef(digestCFData))

    // SecKeyAlgorithm: https://developer.apple.com/documentation/security/seckeyalgorithm
    // SecKeyCreateSignature: https://developer.apple.com/documentation/security/1643916-seckeycreatesignature
    var cfErrorRef C.CFErrorRef
    signCFData := C.SecKeyCreateSignature(
       k.privateKey, C.kSecKeyAlgorithmRSASignatureDigestPSSSHA256,
       C.CFDataRef(digestCFData), &cfErrorRef,
    )
    if cfErrorRef != 0 {
       return nil, fmt.Errorf("failed to sign data: %v", cfErrorRef)
    }
    defer C.CFRelease(C.CFTypeRef(signCFData))

    // Convert CFDataRef to Go byte slice
    return C.GoBytes(unsafe.Pointer(C.CFDataGetBytePtr(signCFData)),
        C.int(C.CFDataGetLength(signCFData))), nil
}

We use the SecKeyCreateSignature function to sign the digest. The function takes the private key reference, the algorithm, the digest, and a pointer to a CFErrorRef. The function returns a CFDataRef, which we convert to a Go byte slice. Additional algorithms can be supported by passing the proper parameter to the SecKeyCreateSignature function.

Putting it all together

With the above code, we can create our new Go mTLS client that uses the macOS keychain.

func main() {

    urlPath := flag.String("url", "", "URL to make request to")
    flag.Parse()
    if *urlPath == "" {
       log.Fatalf("URL to make request to is required")
    }

    client := http.Client{
       Transport: &http.Transport{
          TLSClientConfig: &tls.Config{
             GetClientCertificate: signer.GetClientCertificate,
             MinVersion:           tls.VersionTLS13,
             MaxVersion:           tls.VersionTLS13,
          },
       },
    }

    // Make a GET request to the URL
    rsp, err := client.Get(*urlPath)
    if err != nil {
       log.Fatalf("error making get request: %v", err)
    }
    defer func() { _ = rsp.Body.Close() }()

    // Read the response body
    rspBytes, err := io.ReadAll(rsp.Body)
    if err != nil {
       log.Fatalf("error reading response: %v", err)
    }

    // Print the response body
    fmt.Printf("%s\n", string(rspBytes))
}

We limit the scope of this example to TLS 1.3

Build the mTLS client

With go build client-signer.go, we generate the client-signer executable.

Setting up the environment

The next step is to use the macOS keychain to store the client certificate and private key. We will use the same certificates and keys script from the mTLS with macOS keychain article. If you still need to generate the certificates and keys, please follow the instructions in that article.

We must also import the generated certificates and keys into the macOS keychain.

# Import the server CA
security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain certs/server-ca.crt

# Import the client CA so that client TLS certificates can be verified
security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain certs/client-ca.crt
# Import the client TLS certificate and key
security import certs/client.crt -k /Library/Keychains/System.keychain
security import certs/client.key -k /Library/Keychains/System.keychain -x -T $PWD/client-signer -T /usr/bin/curl -T /Applications/Safari.app -T '/Applications/Google Chrome.app'

We specify our application $PWD/client-signer as one of the trusted applications that can access the private key. If we do not select the trusted application, we will get a security pop-up whenever our app tries to access the private key.

Finally, as in the mTLS Hello World article, we will use docker compose up to start two nginx servers:

  • https://localhost:8888 for TLS
  • https://localhost:8889 for mTLS

Running the Go mTLS client using the macOS keychain

We can now run our mTLS client without pointing to certificate and key files. Hitting the ordinary TLS server:

./client-signer --url https://localhost:8888/hello-world.txt

Returns the expected:

TLS Hello World!

While hitting the mTLS server:

./client-signer --url https://localhost:8889/hello-world.txt

Returns a more detailed message, including the print statements in our custom code:

Server requested certificate
Found 1 identities
Found certificate from issuer CN=testClientCA,OU=Your Unit,O=Your Organization,L=Austin,ST=Texas,C=US with public key type *rsa.PublicKey
crypto.Signer.Public
crypto.Signer.Public
crypto.Signer.Sign with key type *rsa.PublicKey, opts type *rsa.PSSOptions, hash SHA-256
mTLS Hello World!

Using certificate and key from the Windows certificate store

The following article will explore using the Windows certificate store to hold the mTLS client certificate and private key.

Example code on GitHub

The example code is available on GitHub at https://github.com/getvictor/mtls/tree/master/mtls-go-apple-keychain

mTLS Go client using macOS keychain video

Note: If you want to comment on this article, please do so on the YouTube video.