/*** MODULEINFO
<depend>crypto</depend>
+ <depend>curl</depend>
+ <depend>res_curl</depend>
<support_level>core</support_level>
***/
#include "asterisk/sorcery.h"
#include "asterisk/time.h"
#include "asterisk/json.h"
+#include "asterisk/astdb.h"
+#include "asterisk/paths.h"
+#include "asterisk/conversions.h"
#include "asterisk/res_stir_shaken.h"
#include "res_stir_shaken/stir_shaken.h"
#include "res_stir_shaken/general.h"
#include "res_stir_shaken/store.h"
#include "res_stir_shaken/certificate.h"
+#include "res_stir_shaken/curl.h"
+
+/*** DOCUMENTATION
+ <configInfo name="res_stir_shaken" language="en_US">
+ <synopsis>STIR/SHAKEN module for Asterisk</synopsis>
+ <configFile name="stir_shaken.conf">
+ <configObject name="general">
+ <synopsis>STIR/SHAKEN general options</synopsis>
+ <configOption name="type">
+ <synopsis>Must be of type 'general'.</synopsis>
+ </configOption>
+ <configOption name="ca_file" default="">
+ <synopsis>File path to the certificate authority certificate</synopsis>
+ </configOption>
+ <configOption name="ca_path" default="">
+ <synopsis>File path to a chain of trust</synopsis>
+ </configOption>
+ <configOption name="cache_max_size" default="1000">
+ <synopsis>Maximum size to use for caching public keys</synopsis>
+ </configOption>
+ <configOption name="curl_timeout" default="2">
+ <synopsis>Maximum time to wait to CURL certificates</synopsis>
+ </configOption>
+ </configObject>
+ <configObject name="store">
+ <synopsis>STIR/SHAKEN certificate store options</synopsis>
+ <configOption name="type">
+ <synopsis>Must be of type 'store'.</synopsis>
+ </configOption>
+ <configOption name="path" default="">
+ <synopsis>Path to a directory containing certificates</synopsis>
+ </configOption>
+ <configOption name="public_key_url" default="">
+ <synopsis>URL to the public key(s)</synopsis>
+ <description><para>
+ Must be a valid http, or https, URL. The URL must also contain the ${CERTIFICATE} variable, which is used for public key name substitution.
+ For example: http://mycompany.com/${CERTIFICATE}.pub
+ </para></description>
+ </configOption>
+ </configObject>
+ <configObject name="certificate">
+ <synopsis>STIR/SHAKEN certificate options</synopsis>
+ <configOption name="type">
+ <synopsis>Must be of type 'certificate'.</synopsis>
+ </configOption>
+ <configOption name="path" default="">
+ <synopsis>File path to a certificate</synopsis>
+ </configOption>
+ <configOption name="public_key_url" default="">
+ <synopsis>URL to the public key</synopsis>
+ <description><para>
+ Must be a valid http, or https, URL.
+ </para></description>
+ </configOption>
+ </configObject>
+ </configFile>
+ </configInfo>
+ ***/
#define STIR_SHAKEN_ENCRYPTION_ALGORITHM "ES256"
#define STIR_SHAKEN_PPT "shaken"
static struct ast_sorcery *stir_shaken_sorcery;
+/* Used for AstDB entries */
+#define AST_DB_FAMILY "STIR_SHAKEN"
+
+/* The directory name to store keys in. Appended to ast_config_DATA_DIR */
+#define STIR_SHAKEN_DIR_NAME "stir_shaken"
+
+/* The maximum length for path storage */
+#define MAX_PATH_LEN 256
+
struct ast_stir_shaken_payload {
/*! The JWT header */
struct ast_json *header;
ast_free(payload);
}
+/*!
+ * \brief Sets the expiration for the public key based on the provided fields.
+ * If Cache-Control is present, use it. Otherwise, use Expires.
+ *
+ * \param hash The hash for the public key URL
+ * \param data The CURL callback data containing expiration data
+ */
+static void set_public_key_expiration(const char *public_key_url, const struct curl_cb_data *data)
+{
+ char time_buf[32];
+ char *value;
+ struct timeval actual_expires = ast_tvnow();
+ char hash[41];
+
+ ast_sha1_hash(hash, public_key_url);
+
+ value = curl_cb_data_get_cache_control(data);
+ if (!ast_strlen_zero(value)) {
+ char *str_max_age;
+
+ str_max_age = strstr(value, "s-maxage");
+ if (!str_max_age) {
+ str_max_age = strstr(value, "max-age");
+ }
+
+ if (str_max_age) {
+ unsigned int max_age;
+ char *equal = strchr(str_max_age, '=');
+ if (equal && !ast_str_to_uint(equal + 1, &max_age)) {
+ actual_expires.tv_sec += max_age;
+ }
+ }
+ } else {
+ value = curl_cb_data_get_expires(data);
+ if (!ast_strlen_zero(value)) {
+ struct tm expires_time;
+
+ strptime(value, "%a, %d %b %Y %T %z", &expires_time);
+ expires_time.tm_isdst = -1;
+ actual_expires.tv_sec = mktime(&expires_time);
+ }
+ }
+
+ snprintf(time_buf, sizeof(time_buf), "%30lu", actual_expires.tv_sec);
+
+ ast_db_put(hash, "expiration", time_buf);
+}
+
+/*!
+ * \brief Check to see if the public key is expired
+ *
+ * \param public_key_url The public key URL
+ *
+ * \retval 1 if expired
+ * \retval 0 if not expired
+ */
+static int public_key_is_expired(const char *public_key_url)
+{
+ struct timeval current_time = ast_tvnow();
+ struct timeval expires = { .tv_sec = 0, .tv_usec = 0 };
+ char expiration[32];
+ char hash[41];
+
+ ast_sha1_hash(hash, public_key_url);
+ ast_db_get(hash, "expiration", expiration, sizeof(expiration));
+
+ if (ast_strlen_zero(expiration)) {
+ return 1;
+ }
+
+ if (ast_str_to_ulong(expiration, (unsigned long *)&expires.tv_sec)) {
+ return 1;
+ }
+
+ return ast_tvcmp(current_time, expires) == -1 ? 0 : 1;
+}
+
+/*!
+ * \brief Returns the path to the downloaded file for the provided URL
+ *
+ * \param public_key_url The public key URL
+ *
+ * \retval Empty string if not present in AstDB
+ * \retval The file path if present in AstDB
+ */
+static char *get_path_to_public_key(const char *public_key_url)
+{
+ char hash[41];
+ char file_path[MAX_PATH_LEN];
+
+ ast_sha1_hash(hash, public_key_url);
+
+ ast_db_get(hash, "path", file_path, sizeof(file_path));
+
+ if (ast_strlen_zero(file_path)) {
+ file_path[0] = '\0';
+ }
+
+ return ast_strdup(file_path);
+}
+
+/*!
+ * \brief Add the public key details and file path to AstDB
+ *
+ * \param public_key_url The public key URL
+ * \param filepath The path to the file
+ */
+static void add_public_key_to_astdb(const char *public_key_url, const char *filepath)
+{
+ char hash[41];
+
+ ast_sha1_hash(hash, public_key_url);
+
+ ast_db_put(AST_DB_FAMILY, public_key_url, hash);
+ ast_db_put(hash, "path", filepath);
+}
+
+/*!
+ * \brief Remove the public key details and associated information from AstDB
+ *
+ * \param public_key_url The public key URL
+ */
+static void remove_public_key_from_astdb(const char *public_key_url)
+{
+ char hash[41];
+ char filepath[MAX_PATH_LEN];
+
+ ast_sha1_hash(hash, public_key_url);
+
+ /* Remove this public key from storage */
+ ast_db_get(hash, "path", filepath, sizeof(filepath));
+
+ /* Remove the actual file from the system */
+ remove(filepath);
+
+ ast_db_del(AST_DB_FAMILY, public_key_url);
+ ast_db_deltree(hash, NULL);
+}
+
+/*!
+ * \brief Verifies the signature using a public key
+ *
+ * \param msg The payload
+ * \param signature The signature to verify
+ * \param public_key The public key used for verification
+ *
+ * \retval -1 on failure
+ * \retval 0 on success
+ */
+static int stir_shaken_verify_signature(const char *msg, const char *signature, EVP_PKEY *public_key)
+{
+ EVP_MD_CTX *mdctx = NULL;
+ int ret = 0;
+ unsigned char *decoded_signature;
+ size_t signature_length, decoded_signature_length, padding = 0;
+
+ mdctx = EVP_MD_CTX_create();
+ if (!mdctx) {
+ ast_log(LOG_ERROR, "Failed to create Message Digest Context\n");
+ return -1;
+ }
+
+ ret = EVP_DigestVerifyInit(mdctx, NULL, EVP_sha256(), NULL, public_key);
+ if (ret != 1) {
+ ast_log(LOG_ERROR, "Failed to initialize Message Digest Context\n");
+ EVP_MD_CTX_destroy(mdctx);
+ return -1;
+ }
+
+ ret = EVP_DigestVerifyUpdate(mdctx, (unsigned char *)msg, strlen(msg));
+ if (ret != 1) {
+ ast_log(LOG_ERROR, "Failed to update Message Digest Context\n");
+ EVP_MD_CTX_destroy(mdctx);
+ return -1;
+ }
+
+ /* We need to decode the signature from base64 to bytes. Make sure we have
+ * at least enough characters for this check */
+ signature_length = strlen(signature);
+ if (signature_length > 2 && signature[signature_length - 1] == '=') {
+ padding++;
+ if (signature[signature_length - 2] == '=') {
+ padding++;
+ }
+ }
+
+ decoded_signature_length = (signature_length / 4 * 3) - padding;
+ decoded_signature = ast_calloc(1, decoded_signature_length);
+ ast_base64decode(decoded_signature, signature, decoded_signature_length);
+
+ ret = EVP_DigestVerifyFinal(mdctx, decoded_signature, decoded_signature_length);
+ if (ret != 1) {
+ ast_log(LOG_ERROR, "Failed final phase of signature verification\n");
+ EVP_MD_CTX_destroy(mdctx);
+ ast_free(decoded_signature);
+ return -1;
+ }
+
+ EVP_MD_CTX_destroy(mdctx);
+ ast_free(decoded_signature);
+
+ return 0;
+}
+
+/*!
+ * \brief CURL the file located at public_key_url to the specified path
+ *
+ * \param public_key_url The public key URL
+ * \param path The path to download the file to
+ *
+ * \retval -1 on failure
+ * \retval 0 on success
+ */
+static int run_curl(const char *public_key_url, const char *path)
+{
+ struct curl_cb_data *data;
+
+ data = curl_cb_data_create();
+ if (!data) {
+ ast_log(LOG_ERROR, "Failed to create CURL callback data\n");
+ return -1;
+ }
+
+ if (curl_public_key(public_key_url, path, data)) {
+ ast_log(LOG_ERROR, "Could not retrieve public key for '%s'\n", public_key_url);
+ curl_cb_data_free(data);
+ return -1;
+ }
+
+ set_public_key_expiration(public_key_url, data);
+ curl_cb_data_free(data);
+
+ return 0;
+}
+
+/*!
+ * \brief Downloads the public key from public_key_url. If curl is non-zero, that signals
+ * CURL has already been run, and we should bail here. The entry is added to AstDB as well.
+ *
+ * \param public_key_url The public key URL
+ * \param path The path to download the file to
+ * \param curl Flag signaling if we have run CURL or not
+ *
+ * \retval -1 on failure
+ * \retval 0 on success
+ */
+static int curl_and_check_expiration(const char *public_key_url, const char *path, int *curl)
+{
+ if (curl) {
+ ast_log(LOG_ERROR, "Already downloaded public key '%s'\n", path);
+ return -1;
+ }
+
+ if (run_curl(public_key_url, path)) {
+ return -1;
+ }
+
+ if (public_key_is_expired(public_key_url)) {
+ ast_log(LOG_ERROR, "Newly downloaded public key '%s' is expired\n", path);
+ return -1;
+ }
+
+ *curl = 1;
+ add_public_key_to_astdb(public_key_url, path);
+
+ return 0;
+}
+
+struct ast_stir_shaken_payload *ast_stir_shaken_verify(const char *header, const char *payload, const char *signature,
+ const char *algorithm, const char *public_key_url)
+{
+ struct ast_stir_shaken_payload *ret_payload;
+ EVP_PKEY *public_key;
+ char *filename;
+ int curl = 0;
+ struct ast_json_error err;
+ RAII_VAR(char *, file_path, NULL, ast_free);
+
+ if (ast_strlen_zero(header)) {
+ ast_log(LOG_ERROR, "'header' is required for STIR/SHAKEN verification\n");
+ return NULL;
+ }
+
+ if (ast_strlen_zero(payload)) {
+ ast_log(LOG_ERROR, "'payload' is required for STIR/SHAKEN verification\n");
+ return NULL;
+ }
+
+ if (ast_strlen_zero(signature)) {
+ ast_log(LOG_ERROR, "'signature' is required for STIR/SHAKEN verification\n");
+ return NULL;
+ }
+
+ if (ast_strlen_zero(algorithm)) {
+ ast_log(LOG_ERROR, "'algorithm' is required for STIR/SHAKEN verification\n");
+ return NULL;
+ }
+
+ if (ast_strlen_zero(public_key_url)) {
+ ast_log(LOG_ERROR, "'public_key_url' is required for STIR/SHAKEN verification\n");
+ return NULL;
+ }
+
+ /* Check to see if we have already downloaded this public key. The reason we
+ * store the file path is because:
+ *
+ * 1. If, for some reason, the default directory changes, we still know where
+ * to look for the files we already have.
+ *
+ * 2. In the future, if we want to add a way to store the keys in multiple
+ * {configurable) directories, we already have the storage mechanism in place.
+ * The only thing that would be left to do is pull from the configuration.
+ */
+ file_path = get_path_to_public_key(public_key_url);
+
+ /* If we don't have an entry in AstDB, CURL from the provided URL */
+ if (ast_strlen_zero(file_path)) {
+
+ size_t file_path_size;
+
+ /* Remove this entry from the database, since we will be
+ * downloading a new file anyways.
+ */
+ remove_public_key_from_astdb(public_key_url);
+
+ /* Go ahead and free file_path, in case anything was allocated above */
+ ast_free(file_path);
+
+ /* Set up the default path */
+ filename = basename(public_key_url);
+ file_path_size = strlen(ast_config_AST_DATA_DIR) + 3 + strlen(STIR_SHAKEN_DIR_NAME) + strlen(filename) + 1;
+ file_path = ast_calloc(1, file_path_size);
+ snprintf(file_path, sizeof(*file_path), "%s/keys/%s/%s", ast_config_AST_DATA_DIR, STIR_SHAKEN_DIR_NAME, filename);
+
+ /* Download to the default path */
+ if (run_curl(public_key_url, file_path)) {
+ return NULL;
+ }
+
+ /* Signal that we have already downloaded a new file, no reason to do it again */
+ curl = 1;
+
+ /* We should have a successful download at this point, so
+ * add an entry to the database.
+ */
+ add_public_key_to_astdb(public_key_url, file_path);
+ }
+
+ /* Check to see if the key we downloaded (or already had) is expired */
+ if (public_key_is_expired(public_key_url)) {
+
+ ast_debug(3, "Public key '%s' is expired\n", public_key_url);
+
+ remove_public_key_from_astdb(public_key_url);
+
+ /* If this fails, then there's nothing we can do */
+ if (curl_and_check_expiration(public_key_url, file_path, &curl)) {
+ return NULL;
+ }
+ }
+
+ /* First attempt to read the key. If it fails, try downloading the file,
+ * unless we already did. Check for expiration again */
+ public_key = stir_shaken_read_key(file_path, 0);
+ if (!public_key) {
+
+ ast_debug(3, "Failed first read of public key file '%s'\n", file_path);
+
+ remove_public_key_from_astdb(public_key_url);
+
+ if (curl_and_check_expiration(public_key_url, file_path, &curl)) {
+ return NULL;
+ }
+
+ public_key = stir_shaken_read_key(file_path, 0);
+ if (!public_key) {
+ ast_log(LOG_ERROR, "Failed to read public key from '%s'\n", file_path);
+ remove_public_key_from_astdb(public_key_url);
+ return NULL;
+ }
+ }
+
+ if (stir_shaken_verify_signature(payload, signature, public_key)) {
+ ast_log(LOG_ERROR, "Failed to verify signature\n");
+ EVP_PKEY_free(public_key);
+ return NULL;
+ }
+
+ /* We don't need the public key anymore */
+ EVP_PKEY_free(public_key);
+
+ ret_payload = ast_calloc(1, sizeof(*ret_payload));
+ if (!ret_payload) {
+ ast_log(LOG_ERROR, "Failed to allocate STIR/SHAKEN payload\n");
+ return NULL;
+ }
+
+ ret_payload->header = ast_json_load_string(header, &err);
+ if (!ret_payload->header) {
+ ast_log(LOG_ERROR, "Failed to create JSON from header\n");
+ ast_stir_shaken_payload_free(ret_payload);
+ return NULL;
+ }
+
+ ret_payload->payload = ast_json_load_string(payload, &err);
+ if (!ret_payload->payload) {
+ ast_log(LOG_ERROR, "Failed to create JSON from payload\n");
+ ast_stir_shaken_payload_free(ret_payload);
+ return NULL;
+ }
+
+ ret_payload->signature = (unsigned char *)ast_strdup(signature);
+ ret_payload->algorithm = ast_strdup(algorithm);
+ ret_payload->public_key_url = ast_strdup(public_key_url);
+
+ return ret_payload;
+}
+
/*!
* \brief Verifies the necessary contents are in the JSON and returns a
* ast_stir_shaken_payload with the extracted values.
payload = ast_calloc(1, sizeof(*payload));
if (!payload) {
- ast_log(LOG_ERROR, "Failed to allocate STIR_SHAKEN payload\n");
+ ast_log(LOG_ERROR, "Failed to allocate STIR/SHAKEN payload\n");
goto cleanup;
}
/* There are 6 bits to 1 base64 digit, so in order to get the size of the base64 encoded
* signature, we need to multiply by the number of bits in a byte and divide by 6. Since
* there's rounding when doing base64 conversions, add 3 bytes, just in case, and account
- * for padding. Add another byte for the NULL-terminator so we don't lose data.
+ * for padding. Add another byte for the NULL-terminator.
*/
encoded_length = ((signature_length * 4 / 3 + 3) & ~3) + 1;
encoded_signature = ast_calloc(1, encoded_length);
return AST_MODULE_LOAD_SUCCESS;
}
-#undef AST_BUILDOPT_SUM
-#define AST_BUILDOPT_SUM ""
-
AST_MODULE_INFO(ASTERISK_GPL_KEY, AST_MODFLAG_GLOBAL_SYMBOLS | AST_MODFLAG_LOAD_ORDER,
"STIR/SHAKEN Module for Asterisk",
.support_level = AST_MODULE_SUPPORT_CORE,
.unload = unload_module,
.reload = reload_module,
.load_pri = AST_MODPRI_CHANNEL_DEPEND - 1,
+ .requires = "res_curl",
);
--- /dev/null
+/*
+ * Asterisk -- An open source telephony toolkit.
+ *
+ * Copyright (C) 2020, Sangoma Technologies Corporation
+ *
+ * Ben Ford <bford@sangoma.com>
+ *
+ * See http://www.asterisk.org for more information about
+ * the Asterisk project. Please do not directly contact
+ * any of the maintainers of this project for assistance;
+ * the project provides a web site, mailing lists and IRC
+ * channels for your use.
+ *
+ * This program is free software, distributed under the terms of
+ * the GNU General Public License Version 2. See the LICENSE file
+ * at the top of the source tree.
+ */
+
+#include "asterisk.h"
+
+#include "asterisk/utils.h"
+#include "asterisk/logger.h"
+#include "curl.h"
+#include "general.h"
+
+#include <curl/curl.h>
+
+/* Used to check CURL headers */
+#define MAX_HEADER_LENGTH 1023
+
+/* Used for CURL requests */
+#define GLOBAL_USERAGENT "asterisk-libcurl-agent/1.0"
+
+/* CURL callback data to avoid storing useless info in AstDB */
+struct curl_cb_data {
+ char *cache_control;
+ char *expires;
+};
+
+struct curl_cb_data *curl_cb_data_create(void)
+{
+ struct curl_cb_data *data;
+
+ data = ast_calloc(1, sizeof(data));
+
+ return data;
+}
+
+void curl_cb_data_free(struct curl_cb_data *data)
+{
+ if (!data) {
+ return;
+ }
+
+ ast_free(data->cache_control);
+ ast_free(data->expires);
+
+ ast_free(data);
+}
+
+char *curl_cb_data_get_cache_control(const struct curl_cb_data *data)
+{
+ if (!data) {
+ return NULL;
+ }
+
+ return data->cache_control;
+}
+
+char *curl_cb_data_get_expires(const struct curl_cb_data *data)
+{
+ if (!data) {
+ return NULL;
+ }
+
+ return data->expires;
+}
+
+/*!
+ * \brief Called when a CURL request completes
+ *
+ * \param data The curl_cb_data structure to store expiration info
+ */
+static size_t curl_header_callback(char *buffer, size_t size, size_t nitems, void *data)
+{
+ struct curl_cb_data *cb_data = data;
+ size_t realsize;
+ char *header;
+ char *value;
+
+ realsize = size * nitems;
+
+ if (realsize > MAX_HEADER_LENGTH) {
+ ast_log(LOG_WARNING, "CURL header length is too large (size: '%zu' | max: '%d')\n",
+ realsize, MAX_HEADER_LENGTH);
+ return 0;
+ }
+
+ header = ast_alloca(realsize + 1);
+ memcpy(header, buffer, realsize);
+ header[realsize] = '\0';
+ value = strchr(header, ':');
+ if (!value) {
+ return realsize;
+ }
+ *value++ = '\0';
+ value = ast_trim_blanks(ast_skip_blanks(value));
+
+ if (!strcasecmp(header, "Cache-Control")) {
+ cb_data->cache_control = ast_strdup(value);
+ } else if (!strcasecmp(header, "Expires")) {
+ cb_data->expires = ast_strdup(value);
+ }
+
+ return realsize;
+}
+
+/*!
+ * \brief Prepare a CURL instance to use
+ *
+ * \param data The CURL callback data
+ *
+ * \retval NULL on failure
+ * \retval CURL instance on success
+ */
+static CURL *get_curl_instance(struct curl_cb_data *data)
+{
+ CURL *curl;
+ struct stir_shaken_general *cfg;
+ unsigned int curl_timeout;
+
+ cfg = stir_shaken_general_get();
+ curl_timeout = ast_stir_shaken_curl_timeout(cfg);
+ ao2_cleanup(cfg);
+
+ curl = curl_easy_init();
+ if (!curl) {
+ return NULL;
+ }
+
+ curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1);
+ curl_easy_setopt(curl, CURLOPT_TIMEOUT, curl_timeout);
+ curl_easy_setopt(curl, CURLOPT_USERAGENT, GLOBAL_USERAGENT);
+ curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1);
+ curl_easy_setopt(curl, CURLOPT_HEADERFUNCTION, curl_header_callback);
+ curl_easy_setopt(curl, CURLOPT_HEADERDATA, data);
+
+ return curl;
+}
+
+int curl_public_key(const char *public_key_url, const char *path, struct curl_cb_data *data)
+{
+ FILE *public_key_file;
+ long http_code;
+ CURL *curl;
+ char curl_errbuf[CURL_ERROR_SIZE + 1];
+ char hash[41];
+
+ ast_sha1_hash(hash, public_key_url);
+
+ curl_errbuf[CURL_ERROR_SIZE] = '\0';
+
+ public_key_file = fopen(path, "wb");
+ if (!public_key_file) {
+ ast_log(LOG_ERROR, "Failed to open file '%s' to write public key from '%s': %s (%d)\n",
+ path, public_key_url, strerror(errno), errno);
+ return -1;
+ }
+
+ curl = get_curl_instance(data);
+ if (!curl) {
+ ast_log(LOG_ERROR, "Failed to set up CURL isntance for '%s'\n", public_key_url);
+ fclose(public_key_file);
+ return -1;
+ }
+
+ curl_easy_setopt(curl, CURLOPT_URL, public_key_url);
+ curl_easy_setopt(curl, CURLOPT_WRITEDATA, public_key_file);
+ curl_easy_setopt(curl, CURLOPT_ERRORBUFFER, curl_errbuf);
+
+ if (curl_easy_perform(curl)) {
+ ast_log(LOG_ERROR, "%s\n", curl_errbuf);
+ curl_easy_cleanup(curl);
+ fclose(public_key_file);
+ return -1;
+ }
+
+ curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code);
+
+ curl_easy_cleanup(curl);
+ fclose(public_key_file);
+
+ if (http_code / 100 != 2) {
+ ast_log(LOG_ERROR, "Failed to retrieve URL '%s': code %ld\n", public_key_url, http_code);
+ return -1;
+ }
+
+ return 0;
+}