[Asterisk-code-review] res_stir_shaken: Add unit tests for signing and verification. (asterisk[master])

Friendly Automation asteriskteam at digium.com
Wed May 20 09:18:29 CDT 2020


Friendly Automation has submitted this change. ( https://gerrit.asterisk.org/c/asterisk/+/14408 )

Change subject: res_stir_shaken: Add unit tests for signing and verification.
......................................................................

res_stir_shaken: Add unit tests for signing and verification.

Added two unit tests, one for signing and another for verifying.
stir_shaken_sign checks to make sure that all the required parameters
are passed in and then signs the actual payload. If a signature is
produced and a payload returned as a result, the test passes.
stir_shaken_verify takes the signature from a signed payload to verify.
This unit test also verifies that all the required information is passed
in, and then attempts to verify the signature. If verification is
successful and a payload is returned, the test passes.

Change-Id: I9fa43380f861ccf710cd0f6b6c102a517c86ea13
---
M res/res_stir_shaken.c
M res/res_stir_shaken/certificate.c
M res/res_stir_shaken/certificate.h
M res/res_stir_shaken/stir_shaken.c
4 files changed, 459 insertions(+), 1 deletion(-)

Approvals:
  Joshua Colp: Looks good to me, but someone else must approve
  Kevin Harwell: Looks good to me, but someone else must approve
  George Joseph: Looks good to me, approved
  Friendly Automation: Approved for Submit



diff --git a/res/res_stir_shaken.c b/res/res_stir_shaken.c
index 90ceb93..86117cd 100644
--- a/res/res_stir_shaken.c
+++ b/res/res_stir_shaken.c
@@ -35,6 +35,7 @@
 #include "asterisk/pbx.h"
 #include "asterisk/global_datastores.h"
 #include "asterisk/app.h"
+#include "asterisk/test.h"
 
 #include "asterisk/res_stir_shaken.h"
 #include "res_stir_shaken/stir_shaken.h"
@@ -1195,6 +1196,348 @@
 	.read = stir_shaken_read,
 };
 
+#ifdef TEST_FRAMEWORK
+
+static void test_stir_shaken_add_fake_astdb_entry(const char *public_key_url, const char *file_path)
+{
+	struct timeval expires = ast_tvnow();
+	char time_buf[32];
+	char hash[41];
+
+	ast_sha1_hash(hash, public_key_url);
+	add_public_key_to_astdb(public_key_url, file_path);
+	snprintf(time_buf, sizeof(time_buf), "%30lu", expires.tv_sec + 300);
+
+	ast_db_put(hash, "expiration", time_buf);
+}
+
+/*!
+ * \brief Create a private or public key certificate
+ *
+ * \param file_path The path of the file to create
+ * \param private Set to 0 if public, 1 if private
+ *
+ * \retval -1 on failure
+ * \retval 0 on success
+ */
+static int test_stir_shaken_write_temp_key(char *file_path, int private)
+{
+	FILE *file;
+	int fd;
+	char *data;
+	char *type = private ? "private" : "public";
+	char *private_data =
+		"-----BEGIN EC PRIVATE KEY-----\n"
+		"MHcCAQEEIFkNGlrmRky2j7wmjGBGoPFBsyEQELmEYN02BiiG508noAoGCCqGSM49\n"
+		"AwEHoUQDQgAECwCaeAYwVG/FAnEnkwaucz6o047iSWq3cJBBUc0n2ZlUDr5VywAz\n"
+		"MZ86EthIqF3CGZjhLHn0xRITXYwfqTtWBw==\n"
+		"-----END EC PRIVATE KEY-----";
+	char *public_data =
+		"-----BEGIN PUBLIC KEY-----\n"
+		"MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAECwCaeAYwVG/FAnEnkwaucz6o047i\n"
+		"SWq3cJBBUc0n2ZlUDr5VywAzMZ86EthIqF3CGZjhLHn0xRITXYwfqTtWBw==\n"
+		"-----END PUBLIC KEY-----";
+
+	fd = mkstemp(file_path);
+	if (fd < 0) {
+		ast_log(LOG_ERROR, "Failed to create temp %s file: %s\n", type, strerror(errno));
+		return -1;
+	}
+
+	file = fdopen(fd, "w");
+	if (!file) {
+		ast_log(LOG_ERROR, "Failed to create temp %s key file: %s\n", type, strerror(errno));
+		return -1;
+	}
+
+	data = private ? private_data : public_data;
+	if (fputs(data, file) == EOF) {
+		ast_log(LOG_ERROR, "Failed to write temp %s key file\n", type);
+		fclose(file);
+		return -1;
+	}
+
+	fclose(file);
+
+	return 0;
+}
+
+AST_TEST_DEFINE(test_stir_shaken_sign)
+{
+	char *caller_id_number = "1234567";
+	char file_path[] = "/tmp/stir_shaken_private.XXXXXX";
+	RAII_VAR(char *, rm_on_exit, file_path, unlink);
+	RAII_VAR(struct ast_json *, json, NULL, ast_json_free);
+	RAII_VAR(struct ast_stir_shaken_payload *, payload, NULL, ast_stir_shaken_payload_free);
+
+	switch (cmd) {
+	case TEST_INIT:
+		info->name = "stir_shaken_sign";
+		info->category = "/res/res_stir_shaken/";
+		info->summary = "STIR/SHAKEN sign unit test";
+		info->description =
+			"Tests signing a JWT with a private key.";
+		return AST_TEST_NOT_RUN;
+	case TEST_EXECUTE:
+		break;
+	}
+
+	/* We only need a private key to sign */
+	test_stir_shaken_write_temp_key(file_path, 1);
+	test_stir_shaken_create_cert(caller_id_number, file_path);
+
+	/* Test missing header section */
+	json = ast_json_pack("{s: {s: {s: s}}}", "payload", "orig", "tn", caller_id_number);
+	payload = ast_stir_shaken_sign(json);
+	if (payload) {
+		ast_test_status_update(test, "Signed an invalid JWT (missing 'header')\n");
+		test_stir_shaken_cleanup_cert(caller_id_number);
+		return AST_TEST_FAIL;
+	}
+
+	/* Test missing payload section */
+	ast_json_free(json);
+	json = ast_json_pack("{s: {s: s, s: s, s: s, s: s}}", "header", "alg",
+		STIR_SHAKEN_ENCRYPTION_ALGORITHM, "ppt", STIR_SHAKEN_PPT, "typ", STIR_SHAKEN_TYPE,
+		"x5u", "http://testing123");
+	payload = ast_stir_shaken_sign(json);
+	if (payload) {
+		ast_test_status_update(test, "Signed an invalid JWT (missing 'payload')\n");
+		test_stir_shaken_cleanup_cert(caller_id_number);
+		return AST_TEST_FAIL;
+	}
+
+	/* Test missing alg section */
+	ast_json_free(json);
+	json = ast_json_pack("{s: {s: s, s: s, s: s}, s: {s: {s: s}}}", "header", "ppt",
+		STIR_SHAKEN_PPT, "typ", STIR_SHAKEN_TYPE, "x5u", "http://testing123", "payload",
+		"orig", "tn", caller_id_number);
+	payload = ast_stir_shaken_sign(json);
+	if (payload) {
+		ast_test_status_update(test, "Signed an invalid JWT (missing 'alg')\n");
+		test_stir_shaken_cleanup_cert(caller_id_number);
+		return AST_TEST_FAIL;
+	}
+
+	/* Test invalid alg value */
+	ast_json_free(json);
+	json = ast_json_pack("{s: {s: s, s: s, s: s, s: s}, s: {s: {s: s}}}", "header", "alg",
+		"invalid algorithm", "ppt", STIR_SHAKEN_PPT, "typ", STIR_SHAKEN_TYPE,
+		"x5u", "http://testing123", "payload", "orig", "tn", caller_id_number);
+	payload = ast_stir_shaken_sign(json);
+	if (payload) {
+		ast_test_status_update(test, "Signed an invalid JWT (wrong 'alg')\n");
+		test_stir_shaken_cleanup_cert(caller_id_number);
+		return AST_TEST_FAIL;
+	}
+
+	/* Test missing ppt section */
+	ast_json_free(json);
+	json = ast_json_pack("{s: {s: s, s: s, s: s}, s: {s: {s: s}}}", "header", "alg",
+		STIR_SHAKEN_ENCRYPTION_ALGORITHM, "typ", STIR_SHAKEN_TYPE, "x5u", "http://testing123",
+		"payload", "orig", "tn", caller_id_number);
+	payload = ast_stir_shaken_sign(json);
+	if (payload) {
+		ast_test_status_update(test, "Signed an invalid JWT (missing 'ppt')\n");
+		test_stir_shaken_cleanup_cert(caller_id_number);
+		return AST_TEST_FAIL;
+	}
+
+	/* Test invalid ppt value */
+	ast_json_free(json);
+	json = ast_json_pack("{s: {s: s, s: s, s: s, s: s}, s: {s: {s: s}}}", "header", "alg",
+		STIR_SHAKEN_ENCRYPTION_ALGORITHM, "ppt", "invalid ppt", "typ", STIR_SHAKEN_TYPE,
+		"x5u", "http://testing123", "payload", "orig", "tn", caller_id_number);
+	payload = ast_stir_shaken_sign(json);
+	if (payload) {
+		ast_test_status_update(test, "Signed an invalid JWT (wrong 'ppt')\n");
+		test_stir_shaken_cleanup_cert(caller_id_number);
+		return AST_TEST_FAIL;
+	}
+
+	/* Test missing typ section */
+	ast_json_free(json);
+	json = ast_json_pack("{s: {s: s, s: s, s: s}, s: {s: {s: s}}}", "header", "alg",
+		STIR_SHAKEN_ENCRYPTION_ALGORITHM, "ppt", STIR_SHAKEN_PPT, "x5u", "http://testing123",
+		"payload", "orig", "tn", caller_id_number);
+	payload = ast_stir_shaken_sign(json);
+	if (payload) {
+		ast_test_status_update(test, "Signed an invalid JWT (missing 'typ')\n");
+		test_stir_shaken_cleanup_cert(caller_id_number);
+		return AST_TEST_FAIL;
+	}
+
+	/* Test invalid typ value */
+	ast_json_free(json);
+	json = ast_json_pack("{s: {s: s, s: s, s: s, s: s}, s: {s: {s: s}}}", "header", "alg",
+		STIR_SHAKEN_ENCRYPTION_ALGORITHM, "ppt", STIR_SHAKEN_PPT, "typ", "invalid typ",
+		"x5u", "http://testing123", "payload", "orig", "tn", caller_id_number);
+	payload = ast_stir_shaken_sign(json);
+	if (payload) {
+		ast_test_status_update(test, "Signed an invalid JWT (wrong 'typ')\n");
+		test_stir_shaken_cleanup_cert(caller_id_number);
+		return AST_TEST_FAIL;
+	}
+
+	/* Test missing orig section */
+	ast_json_free(json);
+	json = ast_json_pack("{s: {s: s, s: s, s: s, s: s}, s: {s: s}}", "header", "alg",
+		STIR_SHAKEN_ENCRYPTION_ALGORITHM, "ppt", STIR_SHAKEN_PPT, "typ", STIR_SHAKEN_TYPE,
+		"x5u", "http://testing123", "payload", "filler", "filler");
+	payload = ast_stir_shaken_sign(json);
+	if (payload) {
+		ast_test_status_update(test, "Signed an invalid JWT (missing 'orig')\n");
+		test_stir_shaken_cleanup_cert(caller_id_number);
+		return AST_TEST_FAIL;
+	}
+
+	/* Test missing tn section */
+	ast_json_free(json);
+	json = ast_json_pack("{s: {s: s, s: s, s: s, s: s}, s: {s: s}}", "header", "alg",
+		STIR_SHAKEN_ENCRYPTION_ALGORITHM, "ppt", STIR_SHAKEN_PPT, "typ", STIR_SHAKEN_TYPE,
+		"x5u", "http://testing123", "payload", "orig", "filler");
+	payload = ast_stir_shaken_sign(json);
+	if (payload) {
+		ast_test_status_update(test, "Signed an invalid JWT (missing 'tn')\n");
+		test_stir_shaken_cleanup_cert(caller_id_number);
+		return AST_TEST_FAIL;
+	}
+
+	/* Test valid JWT */
+	ast_json_free(json);
+	json = ast_json_pack("{s: {s: s, s: s, s: s, s: s}, s: {s: {s: s}}}", "header", "alg",
+		STIR_SHAKEN_ENCRYPTION_ALGORITHM, "ppt", STIR_SHAKEN_PPT, "typ", STIR_SHAKEN_TYPE,
+		"x5u", "http://testing123", "payload", "orig", "tn", caller_id_number);
+	payload = ast_stir_shaken_sign(json);
+	if (!payload) {
+		ast_test_status_update(test, "Failed to sign a valid JWT\n");
+		test_stir_shaken_cleanup_cert(caller_id_number);
+		return AST_TEST_FAIL;
+	}
+
+	test_stir_shaken_cleanup_cert(caller_id_number);
+
+	return AST_TEST_PASS;
+}
+
+AST_TEST_DEFINE(test_stir_shaken_verify)
+{
+	char *caller_id_number = "1234567";
+	char *public_key_url = "http://testing123";
+	char *header = "{\"header\": \"placeholder\"}";
+	char public_path[] = "/tmp/stir_shaken_public.XXXXXX";
+	char private_path[] = "/tmp/stir_shaken_public.XXXXXX";
+	RAII_VAR(char *, rm_on_exit_public, public_path, unlink);
+	RAII_VAR(char *, rm_on_exit_private, private_path, unlink);
+	RAII_VAR(char *, json_str, NULL, ast_json_free);
+	RAII_VAR(struct ast_json *, json, NULL, ast_json_free);
+	RAII_VAR(struct ast_stir_shaken_payload *, signed_payload, NULL, ast_stir_shaken_payload_free);
+	RAII_VAR(struct ast_stir_shaken_payload *, returned_payload, NULL, ast_stir_shaken_payload_free);
+
+	switch (cmd) {
+	case TEST_INIT:
+		info->name = "stir_shaken_verify";
+		info->category = "/res/res_stir_shaken/";
+		info->summary = "STIR/SHAKEN verify unit test";
+		info->description =
+			"Tests verifying a signature with a public key";
+		return AST_TEST_NOT_RUN;
+	case TEST_EXECUTE:
+		break;
+	}
+
+	/* We need the private key to sign, but we also need the corresponding
+	 * public key to verify */
+	test_stir_shaken_write_temp_key(public_path, 0);
+	test_stir_shaken_write_temp_key(private_path, 1);
+	test_stir_shaken_create_cert(caller_id_number, private_path);
+
+	/* Get the signature */
+	json = ast_json_pack("{s: {s: s, s: s, s: s, s: s}, s: {s: {s: s}}}", "header", "alg",
+		STIR_SHAKEN_ENCRYPTION_ALGORITHM, "ppt", STIR_SHAKEN_PPT, "typ", STIR_SHAKEN_TYPE,
+		"x5u", public_key_url, "payload", "orig", "tn", caller_id_number);
+	signed_payload = ast_stir_shaken_sign(json);
+	if (!signed_payload) {
+		ast_test_status_update(test, "Failed to sign a valid JWT\n");
+		test_stir_shaken_cleanup_cert(caller_id_number);
+		return AST_TEST_FAIL;
+	}
+
+	/* Get the message to use for verification */
+	json_str = ast_json_dump_string(json);
+	if (!json_str) {
+		ast_test_status_update(test, "Failed to create string from JSON\n");
+		test_stir_shaken_cleanup_cert(caller_id_number);
+		return AST_TEST_FAIL;
+	}
+
+	/* Test empty header parameter */
+	returned_payload = ast_stir_shaken_verify("", json_str, (const char *)signed_payload->signature,
+		STIR_SHAKEN_ENCRYPTION_ALGORITHM, public_key_url);
+	if (returned_payload) {
+		ast_test_status_update(test, "Verified a signature with missing 'header'\n");
+		test_stir_shaken_cleanup_cert(caller_id_number);
+		return AST_TEST_FAIL;
+	}
+
+	/* Test empty payload parameter */
+	returned_payload = ast_stir_shaken_verify(header, "", (const char *)signed_payload->signature,
+		STIR_SHAKEN_ENCRYPTION_ALGORITHM, public_key_url);
+	if (returned_payload) {
+		ast_test_status_update(test, "Verified a signature with missing 'payload'\n");
+		test_stir_shaken_cleanup_cert(caller_id_number);
+		return AST_TEST_FAIL;
+	}
+
+	/* Test empty signature parameter */
+	returned_payload = ast_stir_shaken_verify(header, json_str, "",
+		STIR_SHAKEN_ENCRYPTION_ALGORITHM, public_key_url);
+	if (returned_payload) {
+		ast_test_status_update(test, "Verified a signature with missing 'signature'\n");
+		test_stir_shaken_cleanup_cert(caller_id_number);
+		return AST_TEST_FAIL;
+	}
+
+	/* Test empty algorithm parameter */
+	returned_payload = ast_stir_shaken_verify(header, json_str, (const char *)signed_payload->signature,
+		"", public_key_url);
+	if (returned_payload) {
+		ast_test_status_update(test, "Verified a signature with missing 'algorithm'\n");
+		test_stir_shaken_cleanup_cert(caller_id_number);
+		return AST_TEST_FAIL;
+	}
+
+	/* Test empty public key URL */
+	returned_payload = ast_stir_shaken_verify(header, json_str, (const char *)signed_payload->signature,
+		STIR_SHAKEN_ENCRYPTION_ALGORITHM, "");
+	if (returned_payload) {
+		ast_test_status_update(test, "Verified a signature with missing 'public key URL'\n");
+		test_stir_shaken_cleanup_cert(caller_id_number);
+		return AST_TEST_FAIL;
+	}
+
+	/* Trick the function into thinking we've already downloaded the key */
+	test_stir_shaken_add_fake_astdb_entry(public_key_url, public_path);
+
+	/* Verify a valid signature */
+	returned_payload = ast_stir_shaken_verify(header, json_str, (const char *)signed_payload->signature,
+		STIR_SHAKEN_ENCRYPTION_ALGORITHM, public_key_url);
+	if (!returned_payload) {
+		ast_test_status_update(test, "Failed to verify a valid signature\n");
+		remove_public_key_from_astdb(public_key_url);
+		test_stir_shaken_cleanup_cert(caller_id_number);
+		return AST_TEST_FAIL;
+	}
+
+	remove_public_key_from_astdb(public_key_url);
+
+	test_stir_shaken_cleanup_cert(caller_id_number);
+
+	return AST_TEST_PASS;
+}
+
+#endif /* TEST_FRAMEWORK */
+
 static int reload_module(void)
 {
 	if (stir_shaken_sorcery) {
@@ -1217,6 +1560,9 @@
 
 	res |= ast_custom_function_unregister(&stir_shaken_function);
 
+	AST_TEST_UNREGISTER(test_stir_shaken_sign);
+	AST_TEST_UNREGISTER(test_stir_shaken_verify);
+
 	return res;
 }
 
@@ -1248,6 +1594,9 @@
 
 	res |= ast_custom_function_register(&stir_shaken_function);
 
+	AST_TEST_REGISTER(test_stir_shaken_sign);
+	AST_TEST_REGISTER(test_stir_shaken_verify);
+
 	return res;
 }
 
diff --git a/res/res_stir_shaken/certificate.c b/res/res_stir_shaken/certificate.c
index e889a36..73b5ce1 100644
--- a/res/res_stir_shaken/certificate.c
+++ b/res/res_stir_shaken/certificate.c
@@ -244,6 +244,80 @@
 	return 0;
 }
 
+#ifdef TEST_FRAMEWORK
+
+/* Name for test certificaate */
+#define TEST_CONFIG_NAME "test_stir_shaken_certificate"
+/* The public key URL to use for the test certificate */
+#define TEST_CONFIG_URL "http://testing123"
+
+int test_stir_shaken_cleanup_cert(const char *caller_id_number)
+{
+	struct stir_shaken_certificate *cert;
+	struct ast_sorcery *sorcery;
+	int res = 0;
+
+	sorcery = ast_stir_shaken_sorcery();
+
+	cert = stir_shaken_certificate_get_by_caller_id_number(caller_id_number);
+	if (!cert) {
+		return 0;
+	}
+
+	res = ast_sorcery_delete(sorcery, cert);
+	ao2_cleanup(cert);
+	if (res) {
+		ast_log(LOG_ERROR, "Failed to delete sorcery object with caller ID "
+			"'%s'\n", caller_id_number);
+		return -1;
+	}
+
+	res = ast_sorcery_remove_wizard_mapping(sorcery, CONFIG_TYPE, "memory");
+
+	return res;
+}
+
+int test_stir_shaken_create_cert(const char *caller_id_number, const char *file_path)
+{
+	struct stir_shaken_certificate *cert;
+	struct ast_sorcery *sorcery;
+	EVP_PKEY *private_key;
+	int res = 0;
+
+	sorcery = ast_stir_shaken_sorcery();
+
+	res = ast_sorcery_insert_wizard_mapping(sorcery, CONFIG_TYPE, "memory", "testing", 0, 0);
+	if (res) {
+		ast_log(LOG_ERROR, "Failed to insert STIR/SHAKEN test certificate mapping\n");
+		return -1;
+	}
+
+	cert = ast_sorcery_alloc(sorcery, CONFIG_TYPE, TEST_CONFIG_NAME);
+	if (!cert) {
+		ast_log(LOG_ERROR, "Failed to allocate test certificate\n");
+		return -1;
+	}
+
+	ast_string_field_set(cert, path, file_path);
+	ast_string_field_set(cert, public_key_url, TEST_CONFIG_URL);
+	ast_string_field_set(cert, caller_id_number, caller_id_number);
+
+	private_key = stir_shaken_read_key(cert->path, 1);
+	if (!private_key) {
+		ast_log(LOG_ERROR, "Failed to read test key from %s\n", cert->path);
+		test_stir_shaken_cleanup_cert(caller_id_number);
+		return -1;
+	}
+
+	cert->private_key = private_key;
+
+	ast_sorcery_create(sorcery, cert);
+
+	return res;
+}
+
+#endif /* TEST_FRAMEWORK */
+
 int stir_shaken_certificate_unload(void)
 {
 	ast_cli_unregister_multiple(stir_shaken_certificate_cli,
diff --git a/res/res_stir_shaken/certificate.h b/res/res_stir_shaken/certificate.h
index fda3bf1..ff30318 100644
--- a/res/res_stir_shaken/certificate.h
+++ b/res/res_stir_shaken/certificate.h
@@ -24,6 +24,14 @@
 
 struct stir_shaken_certificate;
 
+/*!
+ * \brief Get a STIR/SHAKEN certificate by caller ID number
+ *
+ * \param callier_id_number The caller ID number
+ *
+ * \retval NULL if not found
+ * \retval The certificate on success
+ */
 struct stir_shaken_certificate *stir_shaken_certificate_get_by_caller_id_number(const char *caller_id_number);
 
 /*!
@@ -46,6 +54,33 @@
  */
 EVP_PKEY *stir_shaken_certificate_get_private_key(struct stir_shaken_certificate *cert);
 
+#ifdef TEST_FRAMEWORK
+
+/*!
+ * \brief Clean up the certificate and mappings set up in test_stir_shaken_init
+ *
+ * \param caller_id_number The caller ID of the certificate to clean up
+ *
+ * \retval non-zero on failure
+ * \retval 0 on success
+ */
+int test_stir_shaken_cleanup_cert(const char *caller_id_number);
+
+/*!
+ * \brief Initialize a test certificate through wizard mappings
+ *
+ * \note test_stir_shaken_cleanup should be called when done with this certificate
+ *
+ * \param caller_id_number The caller ID of the certificate to create
+ * \param file_path The path to the private key for this certificate
+ *
+ * \retval non-zero on failure
+ * \retval 0 on success
+ */
+int test_stir_shaken_create_cert(const char *caller_id_number, const char *file_path);
+
+#endif /* TEST_FRAMEWORK */
+
 /*!
  * \brief Load time initialization for the stir/shaken 'certificate' configuration
  *
diff --git a/res/res_stir_shaken/stir_shaken.c b/res/res_stir_shaken/stir_shaken.c
index 10caca9..220104a 100644
--- a/res/res_stir_shaken/stir_shaken.c
+++ b/res/res_stir_shaken/stir_shaken.c
@@ -90,7 +90,7 @@
 
 	fp = fopen(path, "r");
 	if (!fp) {
-		ast_log(LOG_ERROR, "Failed to read private key file '%s'\n", path);
+		ast_log(LOG_ERROR, "Failed to read %s key file '%s'\n", priv ? "private" : "public", path);
 		return NULL;
 	}
 

-- 
To view, visit https://gerrit.asterisk.org/c/asterisk/+/14408
To unsubscribe, or for help writing mail filters, visit https://gerrit.asterisk.org/settings

Gerrit-Project: asterisk
Gerrit-Branch: master
Gerrit-Change-Id: I9fa43380f861ccf710cd0f6b6c102a517c86ea13
Gerrit-Change-Number: 14408
Gerrit-PatchSet: 5
Gerrit-Owner: Benjamin Keith Ford <bford at digium.com>
Gerrit-Reviewer: Friendly Automation
Gerrit-Reviewer: George Joseph <gjoseph at digium.com>
Gerrit-Reviewer: Joshua Colp <jcolp at sangoma.com>
Gerrit-Reviewer: Kevin Harwell <kharwell at digium.com>
Gerrit-MessageType: merged
-------------- next part --------------
An HTML attachment was scrubbed...
URL: <http://lists.digium.com/pipermail/asterisk-code-review/attachments/20200520/7b459d00/attachment-0001.html>


More information about the asterisk-code-review mailing list