External Credential Provider Interface

Sandfly typically uses credentials stored in its own database to log in to target hosts with SSH. These credentials are encrypted using a public key, and only the nodes are configured with the corresponding private key. Thus, a completely compromised server (whether remotely through the API or a local compromise on the server host itself) does not give attackers the ability to decrypt the credentials in the database.

However, organizations may have reasons why storing SSH credentials -- even securely -- in the Sandfly database is not ideal. Requirements for central management of and auditing use of credentials leads to the adoption of credential provider products, or "key vaults", which are able to provide secrets to authorized requesters. The Sandfly server supports retrieving credentials from credential providers at runtime instead of storing the credentials in the database. This capability makes it easier for customers to manage unique credentials per target host, allows the use of short-lived credentials for Sandfly to access target hosts, etc.

To retrieve credentials from an external credential provider, the Sandfly server makes a web service call to a Credential Provider Adapter web service using an interface defined by Sandfly. Customers may implement -- independently or with the assistance of Sandfly -- the Credential Provider Adapter protocol to build an adapter to their credential provider.

Sandfly Credential Configuration

In Sandfly itself, the connection to the credential provider adapter is created like any other credential, but uses the new credential type "External Credential Provider." When creating a new credential of this type, the fields you provide are:

  • URL of external credential adapter service: This is the URL Sandfly will call with the credential request for hosts that are assigned this credential. The format of this request is described in the next section of this document.
  • Unique per host: This is a boolean value indicating whether Sandfly needs to make a new request per host to the credential provider adapter. If you have, for example, a common "sandfly" SSH user with the same password or SSH key on a large group of target hosts, setting "Unique per host" to false will allow Sandfly to make the request to the credential once and re-use the credential it receives (until the credential cache time expires -- see below) on all of the hosts that are assigned to this credential. Typically, this would be the case if you are using a static credential shared across multiple hosts. You would set this to true if the credential provider generates short-lived or unique credentials for each individual host, in which case Sandfly would need to make a credential request for every host it connects to.
  • External credential service extra data: The data in this field will be passed, unaltered, in the request to the credential provider adapter. The use of this field will depend on how the credential provider adapter is designed and implemented, but a typical use may be to distinguish one set of hosts from another at the same credential adapter service URL, or if the credential provider does not provide the username (only the password/SSH key), you may wish to leave the username configurable here rather than in the credential provider adapter configuration. Note that this field is not encrypted in the Sandfly database, so must not be used for any sensitive data.
  • External credential service root CA certificate: If using an HTTPS URL for the external credential adapter service, Sandfly will verify the certificate using the standard trusted CA database included with Ubuntu Linux. If your credential adapter web service is using a self-signed certificate or a certificate issued from an internal CA that is not publicly trusted, you may enter the trusted CA certificate here.

If Sandfly encounters a problem retrieving a credential from the credential provider adapter during a Host Add or Scan operation, the error will be logged in the scanning error log.

The external credential provider type is supported as a credential type during ad-hoc scans via the API.

Credential Provider Adapter Request Specification

When Sandfly needs to connect to a host that is assigned a credential of type External Credential Provider, Sandfly will send an HTTP request to the URL configured in the credential. The request body will contain a JSON object with the following fields:

  • credential_name: (string, required) The name of the credential in Sandfly.
  • extra_data: (string, optional) Any value (possibly an empty string) entered in the External credential service extra data field in the credential.
  • nonce: (string, required) A randomly-generated string that is unique for every request from the Sandfly server.
  • request_time: (string, required) The time the Sandfly server made the request. YYYY-MM-DDTHH:MM:SSZ format.
  • target_host: (string, optional) If the credential in Sandfly is configured with "Unique per host" set to true, the request will include the target address of the host Sandfly is requesting a credential for. This will be the value as originally entered by the Sandfly operator during the "add host" process. E.g. if an IP address or IP range was originally entered, this will be the IP address of the target host. If a hostname was originally entered, this will be the hostname that was entered. If "unique per host" is set to false, this field will not exist.
  • targetport: (int, optional) As with target_host, this field will only be present if "Unique per host" is true. This is the SSH port number of the target host Sandfly is trying to connect to.

In addition to the request body, the server will sign the request to prove to the credential provider adapter that it is genuine.

The request will include an X-Sandfly-Signature HTTP request header which contains a Base64-encoded ed25519 signature of the complete request body. The public key you should use in the credential provider adapter to verify the signature before parsing and responding to the request may be retrieved from the "About Sandfly" section of the "Settings" page from the Sandfly UI, where the server public key is provided as a base64-encoded string.

For example, if the Sandfly UI shows the server public key is 2UVlbv5silqT5bgN6XHqKXUQjLejqXjUlOtyuD7wYNM=, sample Go code to verify the signature on a request would be:

import (
    "crypto/ed25519"
    "encoding/base64"
    "io"
    "net/http"
)

// typically would be a configuration value, not hard-coded
var serverPubKeyB64 = "2UVlbv5silqT5bgN6XHqKXUQjLejqXjUlOtyuD7wYNM="

func requestHandler(w http.ResponseWriter, r *http.Request) {
    serverPubKey, err := base64.StdEncoding.DecodeString(serverPubKeyB64)
    if err != nil {
        // ERROR: invalid server public key
        w.WriteHeader(http.StatusInternalServerError)
        return
    }

    signatureB64 := r.Header.Get("X-Sandfly-Signature")
    if sigb64 == "" {
        // ERROR: missing signature
        w.WriteHeader(http.StatusInternalServerError)
        return
    }

    signature, err := base64.StdEncoding.DecodeString(signatureB64)
    if err != nil {
        // ERROR: invalid signature base64 format
        w.WriteHeader(http.StatusInternalServerError)
        return
    }

    bodyRaw, err := io.ReadAll(r.Body)
    if err != nil {
        // ERROR: unable to read request body
        w.WriteHeader(http.StatusInternalServerError)
        return
    }

    if ok := ed25519.Verify(serverPubKey, bodyRaw, signature); !ok {
        // ERROR: signature on request does not match server's key
        w.WriteHeader(http.StatusInternalServerError)
        return
    }

    // At this point, the request signature confirmed the request was correctly
    // signed by the Sandfly Server's private key. You may proceed to parse the
    // request JSON, and perform additional validation as desired -- e.g.
    // mitigate the possibility of replay attacks (the HTTPS protocol should
    // already cover this possibility, but Sandfly provides a timestamp and
    // nonce if you wish to implement additional protection) by checking that
    // the timestamp is within a small window of the current time and that the
    // nonce hasn't already been used within that timeframe.

    [...]
}

Credential Provider Adapter Response Specification

After receiving a valid credential request from the Sandfly server, the adapter must provide a response containing the credential for the target host.

A successful response must have an HTTP status code of 200 and the response body shall be a JSON object containing the following fields:

  • credentials_type: (string, required) The type of credential. The two allowed values are username, for a username+password credential, and ssh_key, for a username+SSH key credential.
  • encrypted_credential: (string, required) The base64-encoded encrypted credential.
  • ttl: (int, required) The number of seconds for which the Sandfly server may cache this credential. 0 means the credential may not be cached at all. A positive value indicates to the Sandfly server that it may re-use the credential for additional connection attempts to the target host (or other hosts if the credential is not configured as unique per host) during that time interval. The Sandfly server only caches the encrypted credential in memory, it is never written to the database and the nodes do not cache the unencrypted form of the credential. The Sandfly server may request the credential from the credential adapter again at any time, even if it is still within the TTL period of an existing credential request.

The encrypted_credential field, before encryption, is a JSON object with the following fields (which have the same meaning as in the Sandfly server credentials API):

  • username: (string, required) The username to connect to the target host.
  • password: (string, required if credentials_type is username) The password to connect to the target host with, when the credentials_type is username. If credentials_type is ssh_key and the target host requires a sudo password, this value is used.
  • credentials_type: (string, required) username or ssh_key, matching credentials_type in the outer response object.
  • ssh_key_b64: (string, required if credentials_type is ssh_key) The base64-encoded SSH private key to connect to the target host.
  • ssh_key_certificate_b64: (string, optional) The base64-encoded SSH certificate to present to the target host to prove the validity of the SSH private key.
  • ssh_key_password: (string, optional) If the ssh_key_b64 field contains an encrypted private key, the password to decrypt it.

The credential JSON object is encrypted directly to the Node's public key -- even though the Sandfly server is making the request of the external credential adapter, Sandfly's credential handing architecture remains in place than ensures even a complete compromise of the server in a split server--node deployment will not allow an attacker to use the server to steal encrypted credentials.

The credential provider needs to be configured with the node's public key. For initial configuration, this may be found from the Sandfly UI in the About Sandfly section of the Settings page.

To encrypt the credential, use an anonymous sealed box implementation that is interoperable with the libsodium encryption library. Encrypt the credential JSON object to the node's ed25519 public key, base64-encode it, and put the value in the encrypted_credential field of the response object.

For example, in Go:

import (
    "crypto/ed25519"
    "encoding/base64"
    "golang.org/x/crypto/nacl/box"
)

    func encryptCredential(nodePubKeyB64 string, credentialJSON []byte) {
        // node public key input would be in credential adapter configuration
        nodePubKey, err := base64.StdEncoding.DecodeString(
            "Z3onNCnFDtGvFN2QS/EZXOiN9/Zy0uh4IiEtmo9asD8=")
        if err != nil {
            // ERROR: invalid base64 encoding of node public key
            return
        }
        if len(nodePubKey) != ed25519.PublicKeySize {
            // ERROR: node public key is not a valid ed25519 public key
            return
        }

        nodePubKeyPtr := (*[ed25519.PublicKeySize]byte)(nodePubKey)
        ciphertext, err := box.SealAnonymous(nil, credentialJSON,
            nodePubKeyPtr, nil)
        if err != nil {
            // ERROR: couldn't encrypt data
            return
        }

        // At this point, ciphertext has the encrypted credential JSON.
        // Base64-encode it.
        finalValue := base64.StdEncoding.EncodeToString(ciphertext)

        // Use finalValue as the value in the response JSON
        // encrypted_credential field.
    }

Thus, a complete HTTP response body for an SSH credential would look like:

{  
    "credentials_type": "ssh_key",  
    "encrypted_credential": "TFMwdExTMUNSVWRKVGlCUFVFVk9VMU5J...",  
    "ttl": 300  
}

Where the value in encrypted_credential, before encrypting and base64-encoding, might look like the following JSON:

{  
    "username": "sandfly",  
    "credentials_type": "ssh_key",  
    "ssh_key_b64": "LS0tLS1CRUdJTiBPUEVOU1NIIFBSS..."  
}