Skip to main content

Automatically Secure Code-Signing Assets

Lyndseyferguson Lyndsey Ferguson

Header (8)

 

Problem

As I wrote previously in “Bring Customers Joy With Automation”, we were faced with the challenge of safely securing the customer’s signing assets in an automated way.

At this stage, we still had to manually process the data provided from the Appian Provisioner. We wanted to improve our process to make sure that there was no chance that we could see the passwords for our customers’ keychains.

Remember, the keychain contains a certificate and a private key that can be used to code-sign a mobile application to prove to users that the application was made by the company and was not modified by someone who may want to harm the company or its users. The Appian Provisioner application encrypts this data before it is ever sent to us.

We needed to secure the data sent to us from the Appian Provisioner in such a way that Appian employees were only allowed to store the customer’s data without being able to read it. We also had to make sure that only the build machines could read and decrypt the customer’s credentials in order to code-sign their custom mobile applications.

Solution

Appian uses Hashicorp Vault to store this kind of data. Vault is a server that secures, stores, and tightly controls access to tokens, passwords, certificates, and encryption keys for protecting secrets and other sensitive data using a website UI, command line interface, or HTTP API endpoints.

To ensure that each entity had the correct permissions, we decided to use Vault’s policies and token roles. A Vault policy allows you to configure authorization access to create, update, read, list, and delete secrets. Policies can be associated with users or groups. A Vault token role allows you to provide a token to another user to take on a role that uses a given policy.

For example, there may exist a token role that is tied to a policy that allows a token-holder to read a particular secret. I may not be able to read that secret, but I could create a token that allows you to login on your computer and read that secret without allowing me to do the same.

So, that’s what we did. We created two distinct policies: one that allowed us to write — and only write — customer data, and the other that allowed an automated system to read — and only read — customer data. We also secured the method of decrypting the customer’s passwords.

I’ll guide you on how to do each of these things so that you too can use this Vault functionality to secure your own sensitive data.

Vault Setup

To get the most out of this article, I suggest that you set up an instance of Vault as explained in the article Docker compose — Hashicorp’s Vault and Consul Part A. For this example, you can stop after you’ve unsealed Vault (before auditing is enabled).

Once you set up your own debug instance of Vault, if you haven’t already, run this command inside of your Vault container:

vault kv enable-versioning secret

This will allow you to store multiple versions of secrets. If you make a mistake, you can simply revert back to an older version of the secret.

Encryption Keys

As I wrote above, the Appian Provisioner encrypts the customer’s keychain password and APNS key, and we needed our build system to decrypt this data. To do this, we used a public and private key pair to encrypt and decrypt the data.

I used the article Encrypt and decrypt files to public keys via the OpenSSL Command Line as a reference to create the following script that creates a OpenSSL private key and public key in order to perform Public-key encryption.

 

#!/usr/bin/env sh

# Restrict standard system command line tools.
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

HERE="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )"

# make sure that any error exits the script immediately
set -e

read -p "Admin Vault Token: " VAULT_TOKEN

# create a random password for the private key
CRYPTO_PASSWORD="$(openssl rand -hex 20)"

# generate the private key in the process (no files written to disk)
PRIVATE_KEY="$(openssl genrsa -aes256 -passout pass:"$CRYPTO_PASSWORD" 8192)"
echo "$PRIVATE_KEY" > $HERE/crypto.key

# encode the private key to text so we can put it into Vault
ENCODED_PRIVATE_KEY="$(echo "$PRIVATE_KEY" | base64 - | tr -d '\n')"

# generate the public key from the private key so that it can be used to encrypt data
echo "$PRIVATE_KEY" | openssl rsa -passin pass:"$CRYPTO_PASSWORD" -pubout -out $HERE/vault/public-mobile-apps.key

# write the encoded private key and the private key password to Vault
curl \
   -H "X-Vault-Token: $VAULT_TOKEN" \
   -H "Content-Type: application/json" \
   -X POST \
   -d "{\"data\": {\"encoded_private_key\":\"$ENCODED_PRIVATE_KEY\", \"passphrase\": \"$CRYPTO_PASSWORD\"}}" \
     "http://127.0.0.1:8200/v1/secret/data/custom-mobile-apps/crypto"

unset VAULT_TOKEN
unset ENCODED_PRIVATE_KEY
unset CRYPTO_PASSWORD


This writes the private key its passphrase to the Vault path: secret/data/custom-mobile-apps/crypto. If you followed the Docker compose article I referenced above, you can run this script from the Vault-Consul-Docker/ directory and use the Initial Root Token for the purpose of debugging (each Admin user for Vault should have their own tokens and one shouldn’t normally use the Root Token).

Allow Users to Only Write Customer Data to Vault

We want to secure the customer’s credentials in Vault, but that means that we have to first allow the human user the ability to write into Vault.

Let’s create Vault policies that allow that by defining it:

path "secret/data/custom-mobile-apps/keychains/*" {
  capabilities = ["create", "update"]
}

path "secret/data/custom-mobile-apps/apns_keys/*" {
  capabilities = ["create", "update"]
}


Users with this policy are allowed to write keychain and APNS key data to Vault with the specified paths.

Now that we have defined the policy, we have to write it into Vault. From the Vault Container, run the following command:

vault policy write custom-mobile-apps-write-policy \
vault/policies/custom-mobile-apps-write-policy.hcl

Now that we have a policy that allows writing into Vault, that policy has to be associated with the corresponding users. Vault does not come enabled with an auth backend for user authorization, so we have to enable one. For the purpose of this article, let’s enable the simple username/password authorization so we can create a test user account. From within the Vault Container, run the following:

vault auth enable userpass

Next, create the user with the write-only policy by running the following in the Vault Container:

vault write auth/userpass/users/myuser \
password=mypassword \
policies=custom-mobile-apps-write-policy

That’s enough to allow that user to write custom-mobile-apps secrets.

Secure Keychain Data

We now allow select users to write secrets into Vault. Remember, we want to write the customer’s encrypted keychain password. For the convenience of keeping the two pieces of data together, we also want to store the keychain alongside it. Both of these pieces of data are binary, which Vault cannot store. Instead, we have to base 64 encode it.

Here’s a script that does just that. I added a bit more code to pretend that I received the encrypted keychain password.

 

#!/usr/bin/env sh

# Restrict standard system command line tools.
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

set -e
set -x

###############################################################################
# Checks whether or not the VAULT_TOKEN can be used to create or update
# the Vault secret at the given secret path.
# Globals:
#   VAULT_TOKEN
# Arguments:
#   a path to the secret in Vault. i.e. 'secret/data/custom-mobile-apps/keychains/example_corporation'
###############################################################################
check_token_for_create_update_capabilities() {
  local secret_path
  secret_path=$1

  capabilities=$(curl -s --request POST --header "X-Vault-Token: $VAULT_TOKEN" \
    --data "{\"path\":\"$secret_path\"}" \
    $VAULT_ADDR/v1/sys/capabilities-self | jq  ".capabilities")

  has_create_update_capabilities="$(echo $capabilities | jq "[. | sort | index(\"create\"), index(\"update\")] == [0, 1]")"

  if [ "$has_create_update_capabilities" == "false" ]; then
    echo "Error: current VAULT_TOKEN does not allow to write to '$secret_path'"
    exit 1
  fi
}

# Make sure that we can write keychains and apns_keys secrets
check_token_for_create_update_capabilities "secret/data/custom-mobile-apps/keychains/*"
check_token_for_create_update_capabilities "secret/data/custom-mobile-apps/apns_keys/*"

# Ensure the 1st parameter to script is a keychain, and 2nd is an APNS key
KEYCHAIN_FILEPATH=$1
if [ ! -f "$KEYCHAIN_FILEPATH" ]; then
  echo "Error: the first parameter must be an existing keychain filepath"
  exit 1
fi
APNS_PRIVATE_KEY_FILE_PATH=$2
if [ ! -f "$APNS_PRIVATE_KEY_FILE_PATH" ]; then
  echo "Error: the second parameter must be an existing APNS key filepath"
  exit 1
fi

# Vault does not support storing and retrieving binary data: we need to encode
# binary data into base64 in order to put it into Vault (remove extra newlines)
KEYCHAIN_ENCODED_DATA="$(base64 -w 0 "$KEYCHAIN_FILEPATH")"
read -sp "Keychain password: " KEYCHAIN_PASSWORD
echo ""

ENCRYPTED_KEYCHAIN_PASSWORD_FILEPATH=$(mktemp /tmp/XXXXXX-encrypted-keychain-password.enc)
echo "$KEYCHAIN_PASSWORD" | tr -d '\n' | openssl rsautl -encrypt -pubin -inkey vault/public-mobile-apps.key -out $ENCRYPTED_KEYCHAIN_PASSWORD_FILEPATH

ENCODED_ENCRYPTED_KEYCHAIN_PASSWORD="$(cat "$ENCRYPTED_KEYCHAIN_PASSWORD_FILEPATH" | base64 -w 0)"
rm "$ENCRYPTED_KEYCHAIN_PASSWORD_FILEPATH" # Delete the password file, just to be safe
read -p "Company name: " COMPANY_NAME
echo ""
echo "$ENCODED_ENCRYPTED_KEYCHAIN_PASSWORD" > "${COMPANY_NAME}-encoded-encrypted-keychain-password.enc"

# POST to the Vault server in order to write the keychain and the encrypted
# keychain password for the customer.
curl \
 -H "X-Vault-Token: $VAULT_TOKEN" \
 -H "Content-Type: application/json" \
 -X POST \
 -d "{\"data\": {\"keychain_encoded_data\":\"$KEYCHAIN_ENCODED_DATA\", \"encoded_encrypted_keychain_password\": \"$ENCODED_ENCRYPTED_KEYCHAIN_PASSWORD\"}}" \
 "$VAULT_ADDR/v1/secret/data/custom-mobile-apps/keychains/$COMPANY_NAME"

# Delete the encoded encrypted file, just to be safe
rm "${COMPANY_NAME}-encoded-encrypted-keychain-password.enc"

# POST to the Vault server in order to write the encrypted APNS key for the customer.
curl \
 -H "X-Vault-Token: $VAULT_TOKEN" \
 -H "Content-Type: application/json" \
 -X POST \
 -d "{\"data\": {\"apns_private_key\":\"$(cat "$APNS_PRIVATE_KEY_FILE_PATH")\"}}" \
 "$VAULT_ADDR/v1/secret/data/custom-mobile-apps/apns_keys/$COMPANY_NAME"

echo "Keychain data secured into Vault"

unset ENCODED_ENCRYPTED_KEYCHAIN_PASSWORD
unset ENCRYPTED_KEYCHAIN_PASSWORD_FILEPATH
unset KEYCHAIN_ENCODED_DATA
unset APNS_PRIVATE_KEY_FILE_PATH
unset KEYCHAIN_FILEPATH


Let’s login as myuser in order to get a new client_token.

curl --request POST --data '{"password": "mypassword"}' \  
$VAULT_ADDR/v1/auth/userpass/login/myuser# copy the value for 'client_token' in the 'auth'# object in the returned value
export CLIENT_TOKEN=<copied client token>

Now that we have the script, and we have a token that allows us to write to the keychains and APNS secrets, let’s do that:

# we want to pass in the client_token
# as the VAULT_TOKEN for the script to useVAULT_TOKEN=$CLIENT_TOKEN bash ./secure-keychain.sh \
$HOME/myuser.keychain-db \
$HOME/apns.txt

It will ask for the keychain password (hidden) and the company name associated with these signing credentials.

Now, whenever a customer updates the code signing assets, I only have to log into Vault and run this script.

Secure Code-Signing

We now have the keychain and its encrypted password in Vault. The build system needs to retrieve the keychain, unlock it, and then code sign the mobile application.

We use Jenkins for our build system. Jenkins builds can accept parameters, so we will create one to pass in a temporary token that can only be used to read the customer’s secret on a separate build machine that is locked down to only allow Jenkins access. The environment variables that Jenkins has to set are: VAULT_TOKEN and VAULT_ADDR.

I won’t show how to configure Jenkins here but instead assume that you can do something equivalent with your CI system. Instead, I will demonstrate how fastlane can fetch the customer’s credentials onto the build machine, decrypt the keychain password, and unlock the keychain in preparation for code signing.

If you’re following along, I suggest that you install and initialize fastlane. Respond ‘y’ to the prompt to “manually setup a fastlane config”. This will create a basic installation of fastlane with a basic Fastfile in the fastlane directory.

We begin by creating a new fastlane action named get_keychain_from_vault. A fastlane action is a Ruby file that is loaded into fastlane and encapsulates a single piece of functionality for maintainability and reusability. In the Terminal, change the working directory to the parent directory of your fastlane directory, and type:

fastlane new_action
# When prompted ‘name of your action:`, type get_keychain_from_vault

Here is the Ruby code for that fastlane action:

module Fastlane
  module Actions
    require 'openssl'
    require 'vault'
    require 'tmpdir'

    class GetKeychainFromVaultAction < Action
      def self.run(params)
        Vault.address = params[:vault_addr]
        Vault.token = params[:vault_token]
 
        # get the private key that can decrypt encrypted
        # text from Vault
        private_key, private_key_passphrase = private_key_data
        
        # create an OpenSSL entity that can decrypt using
        # that private key
        decrypter = OpenSSL::PKey::RSA.new(private_key, private_key_passphrase)

        # get the keychain and encrypted keychain password
        # from Vault
        keychain_name = params[:keychain_name]
        encoded_keychain_data, encrypted_keychain_password = keychain_data(keychain_name)
        
        # use the decrypter to decrypt the keychain password
        keychain_password = decrypter.private_decrypt(encrypted_keychain_password)
        

        # write the keychain to the disk 
        decoded_keychain_filepath = params[:keychain_path] || tmp_keychain_filepath(keychain_name)

        File.open(decoded_keychain_filepath, 'wb') do |f|
          f.write(Base64.decode64(encoded_keychain_data))
        End

        # arrange to delete the keychain once fastlane finishes
        if params[:keychain_path].nil?
          at_exit do
            FileUtils.rm_rf(decoded_keychain_filepath)
          end
        end

        # return both the path to the keychain and the
        # keychain password to the caller
        # NOTE: remember, this is done on a secured
        # CI build machine
        return {
          keychain_path: decoded_keychain_filepath,
          keychain_password: keychain_password
        }
      end

      # make a temporary keychain file path to save to
      def self.tmp_keychain_filepath
          keychain_name = "#{SecureRandom.urlsafe_base64}.keychain-db"
          File.join(Dir.tmpdir, keychain_name)
      end

      def self.keychain_data(keychain_name)
        secret_path = "secret/data/custom-mobile-apps/keychains/#{keychain_name}"
        keychain_secret = Vault.logical.read(secret_path)
        encoded_keychain_data = keychain_secret.data.dig(:data, :keychain_encoded_data)
        encoded_encrypted_keychain_password  = keychain_secret.data.dig(:data, :encoded_encrypted_keychain_password)
        encrypted_keychain_password = Base64.decode64(encoded_encrypted_keychain_password)
        
        [encoded_keychain_data, encrypted_keychain_password]
      end

      def self.private_key_data
        secret_path = "secret/data/custom-mobile-apps/crypto"
        crypto_secret = Vault.logical.read(secret_path)

        encoded_private_key = crypto_secret.data.dig(:data, :encoded_private_key)
        passphrase = crypto_secret.data.dig(:data, :passphrase)
        
        private_key = Base64.decode64(encoded_private_key)
        [private_key, passphrase]
      end

      #####################################################
      # @!group Documentation
      #####################################################

      def self.description
        "Gets a given keychain from Vault and unlocks it"
      end

      def self.details
        "Gets a given keychain and its encrypted password from Vault. Decrypts the encrypted password provides both to the callee"
      end

      def self.available_options
        [
          FastlaneCore::ConfigItem.new(
            key: :vault_addr,
            env_name: "VAULT_ADDR", 
            description: "The address of the Vault server expressed as a URL and port, for example: https://127.0.0.1:8200/",
          ),
          FastlaneCore::ConfigItem.new(
            key: :vault_token,
            env_name: "VAULT_TOKEN",
            description: "Vault authentication token",
            is_string: false, 
            default_value: false
          ),
          FastlaneCore::ConfigItem.new(
            key: :keychain_name,
            description: "The name of the custom mobile apps keychain"
          ),
          FastlaneCore::ConfigItem.new(
            key: :keychain_path,
            description: "Where to write the retrieved keychain. Defaults to a temporary file that is deleted when the Ruby process unloads",
            optional: true
          )
        ]
      end


      def self.return_value
        "A hash containing a :keychain_path and a :keychain_password"   
      end

      def self.authors
        ["lyndsey-ferguson/lyndseydf"]
      end

      def self.is_supported?(platform)
        [:ios, :mac].include?(platform)
      end
    end
  end
end

Vault Token Role

Finally, to create a token that can only be used by the CI build machines to read secrets, we have to create a token role.

First, we have to define what the token can have access to (remember, we only want it to be able to read the customer’s credentials). Here is the definition:

path "secret/data/custom-mobile-apps/keychains/*" {
  capabilities = ["read"]
}

path "secret/data/custom-mobile-apps/apns_keys/*" {
  capabilities = ["read"]
}

# the CI build machine also needs read access to get the
# crypto key to decrypt the customer’s keychain password
path "secret/data/custom-mobile-apps/crypto" {
  capabilities = ["read"]
}

Then, from within the Vault container, write the policy into the system:

vault policy write custom-mobile-apps-read-policy \
vault/policies/custom-mobile-apps-read-policy.hcl

After creating the policy that allows reading the customer’s secret, we want it to be associated with some entity. That’s where the Vault Token Role comes in — it allows someone to take on the role which can create a read token.

Let’s create a new token role with the read-only policy. From within the Vault container, run:

# create the token role and declare which policies it can access
vault write \
auth/token/roles/custom-mobile-apps-read-policy-create-role \
allowed_policies=custom-mobile-apps-read-policy

We now have a token role that can read the secret, so we have to give the users the ability to create a token that allows others to take on that role. That means a new policy has to be created:

# create the vault policy to create the token
# I would suggest adding a CIDR restriction to only allow this
# policy to be used on one of the CI build machines
path "auth/token/create/custom-mobile-apps-read-policy-create-role" {
  capabilities =  ["create", "update"]
}

Next, let’s create the actual policy that allows the creation of the token role. From within the Vault Container, run:

vault policy write custom-mobile-apps-read-policy-create-role \
vault/policies/custom-mobile-apps-read-policy-create-token-policy.hcl

Finally, we have to update the user account to also permit the creation of the token roles. Still within the Vault Container, run:

# update the user with the policies
vault write auth/userpass/users/myuser \
policies=”custom-mobile-apps-write-policy,custom-mobile-apps-read-policy-create-role”

The user can now create a token that can be used by the CI build machines to read the customer’s secrets. Since we just changed the user’s policy, that user has to re-login to get an updated token that is associated with both policies.

Once a new token is retrieved, run the following to get the token that the CI build system needs:

curl --header "X-Vault-Token: $CLIENT_TOKEN" --request POST \
$VAULT_ADDR/v1/auth/token/create/custom-mobile-apps-read-policy-create-role

Once you get that token, put it into your CI system’s VAULT_TOKEN parameter so that it can be used by the build system. Here is an example Fastfile that fastlane will use to retrieve the keychain data to unlock the keychain safely and securely:

default_platform(:ios)

platform :ios do
  desc "Demonstrate how to get a keychain from Vault and unlock it"
  lane :keychain_from_vault_test do
    keychain_data =  get_keychain_from_vault(
        vault_addr: ENV[‘$VAULT_ADDR’],
        keychain_path: File.expand_path('~/Desktop/my.keychain-db')
    )
    unlock_keychain(path: keychain_data[:keychain_path], password: keychain_data[:keychain_password])
    delete_keychain(keychain_path: keychain_data[:keychain_path])
  end

  desc "Demonstrate how to get a temporary keychain from Vault and unlock it"
  lane :tmp_keychain_from_vault_test do
    keychain_data =  get_keychain_from_vault(
        vault_addr: ENV[‘$VAULT_ADDR’]
    )
    unlock_keychain(path: keychain_data[:keychain_path], password: keychain_data[:keychain_password])
  end
end

Let’s test this lane, which is like a command, to test the retrieval of a keychain, the decryption of the keychain password, and unlocking the keychain.

VAULT_TOKEN=$ROLE_TOKEN fastlane keychain_from_vault_test

Final Thoughts

I’ve given you a walk through of how Vault’s Token Roles can allow one user to grant another user (even a machine) access to secrets on a temporary basis. You can review all of the example code in this GitHub repo.

With Vault, there are a lot more options that you should consider. As demonstrated above, tokens can be restricted to specific CIDRs, but you can do a lot more. For example, you can make them valid for only 1 use, valid for a short period of time, etc. Take a look at what you can do with a policy associated with the Token Role to get a better idea.

Keep in mind that this is not the final result. In another article, I will demonstrate how we ended up using Vault’s AppRoles to improve the security around these secrets even further.

Lyndseyferguson

Written by

Lyndsey Ferguson

Lyndsey is a Principal Software Engineer at Appian.