Commit 2ec62369 authored by johan's avatar johan
Browse files

Add set/get x3dh server url interface

parent cb39b74e
......@@ -138,9 +138,9 @@ namespace lime {
* A user is identified by its deviceId (shall be the GRUU) and must at creation select a base Elliptic curve to use, this setting cannot be changed later
* A user is published on an X3DH key server who must run using the same elliptic curve selected for this user (creation will fail otherwise), the server url cannot be changed later
*
* @param[in] localDeviceId Identify the local user acount to use, it must be unique and is also be used as Id on the X3DH key server, it shall be the GRUU
* @param[in] localDeviceId Identify the local user account, it must be unique and is also be used as Id on the X3DH key server, it shall be the GRUU
* @param[in] x3dhServerUrl The complete url(including port) of the X3DH key server. It must connect using HTTPS. Example: https://sip5.linphone.org:25519
* @param[in] curve Choice of elliptic curve to use as base for ECDH and EdDSA operation involved. Can be CurveId::c25519 or CurveId::c448.
* @param[in] curve Choice of elliptic curve used as base for ECDH and EdDSA operation involved. Can be CurveId::c25519 or CurveId::c448.
* @param[in] OPkInitialBatchSize Number of OPks in the first batch uploaded to X3DH server
* @param[in] callback This operation contact the X3DH server and is thus asynchronous, when server responds,
* this callback will be called giving the exit status and an error message in case of failure
......@@ -328,6 +328,27 @@ namespace lime {
*/
void delete_peerDevice(const std::string &peerDeviceId);
/**
* @brief Set the X3DH key server URL for this identified user
*
* @param[in] localDeviceId Identify the local user account, it must be unique and is also be used as Id on the X3DH key server, it shall be the GRUU
* @param[in] x3dhServerUrl The complete url(including port) of the X3DH key server. It must connect using HTTPS. Example: https://sip5.linphone.org:25519
*
* Throw an exception if the user is unknow or inactive
*/
void set_x3dhServerUrl(const std::string &localDeviceId, const std::string &x3dhServerUrl);
/**
* @brief Get the X3DH key server URL for this identified user
*
* @param[in] localDeviceId Identify the local user account, it must be unique and is also be used as Id on the X3DH key server, it shall be the GRUU
*
* @return The complete url(including port) of the X3DH key server.
*
* Throw an exception if the user is unknow or inactive
*/
std::string get_x3dhServerUrl(const std::string &localDeviceId);
LimeManager() = delete; // no manager without Database and http provider
LimeManager(const LimeManager&) = delete; // no copy constructor
LimeManager operator=(const LimeManager &) = delete; // nor copy operator
......
......@@ -389,7 +389,30 @@ int lime_ffi_delete_peerDevice(lime_manager_t manager, const char *peerDeviceId)
*
* @return LIME_FFI_SUCCESS or a negative error code
*/
int lime_ffi_update(lime_manager_t manager, const lime_ffi_Callback callback, void *callbackUserData, uint16_t OPkServerLowLimit, uint16_t OPkBatchSize);
int lime_ffi_update(lime_manager_t manager, const lime_ffi_Callback callback, void *callbackUserData, uint16_t OPkServerLowLimit, uint16_t OPkBatchSize);
/**
* @brief Set the X3DH key server URL for this identified user
*
* @param[in] manager pointer to the opaque structure used to interact with lime
* @param[in] localDeviceId Identify the local user account, it must be unique and is also be used as Id on the X3DH key server, it shall be the GRUU
* @param[in] x3dhServerUrl The complete url(including port) of the X3DH key server. It must connect using HTTPS. Example: https://sip5.linphone.org:25519
*
* @return LIME_FFI_SUCCESS or a negative error code
*/
int lime_ffi_set_x3dhServerUrl(lime_manager_t manager, const char *localDeviceId, const char *x3dhServerUrl);
/**
* @brief Get the X3DH key server URL for this identified user
*
* @param[in] manager pointer to the opaque structure used to interact with lime
* @param[in] localDeviceId Identify the local user account, it must be unique and is also be used as Id on the X3DH key server, it shall be the GRUU, in a NULL terminated string
* @param[in] x3dhServerUrl The complete url(including port) of the X3DH key server in a NULL terminated string
* @param[in/out] x3dhServerUrlSize Size of the previous buffer, is updated with actual size of data written(without the '\0', would give the same result as strlen.)
*
* @return LIME_FFI_SUCCESS or a negative error code
*/
int lime_ffi_get_x3dhServerUrl(lime_manager_t manager, const char *localDeviceId, char *x3dhServerUrl, size_t *x3dhServerUrlSize);
#ifdef __cplusplus
}
......
......@@ -143,7 +143,7 @@ public class LimeManager {
this.n_encrypt(localDeviceId, recipientUserId, recipients, plainMessage, cipherMessage, statusObj, encryptionPolicy.getNative());
}
/**
* @overload encrypt(String localDeviceId, String recipientUserId, RecipientData[] recipients, byte[] plainMessage, LimeOutputBuffer cipherMessage, LimeStatusCallback statusObj
* @overload encrypt(String localDeviceId, String recipientUserId, RecipientData[] recipients, byte[] plainMessage, LimeOutputBuffer cipherMessage, LimeStatusCallback statusObj)
* convenience form using LimeEncryptionPolicy.OPTIMIZEUPLOADSIZE as default policy
*/
public void encrypt(String localDeviceId, String recipientUserId, RecipientData[] recipients, byte[] plainMessage, LimeOutputBuffer cipherMessage, LimeStatusCallback statusObj) {
......@@ -289,6 +289,25 @@ public class LimeManager {
*/
public native void delete_peerDevice(String peerDeviceId);
/**
* @brief Set the X3DH key server URL for this identified user
* if specified localDeviceId is not found in local Storage, throw an exception
*
* @param[in] localDeviceId Identify the local user acount to use, it must be unique and is also be used as Id on the X3DH key server, it shall be the GRUU
* @param[in] serverURL The complete url(including port) of the X3DH key server. It must connect using HTTPS. Example: https://sip5.linphone.org:25519
*/
public native void set_x3dhServerUrl(String localDeviceId, String serverURL) throws LimeException;
/**
* @brief Get the X3DH key server URL for this identified user
* if specified localDeviceId is not found in local Storage, throw an exception
*
* @param[in] localDeviceId Identify the local user acount to use, it must be unique and is also be used as Id on the X3DH key server, it shall be the GRUU
*
* @return serverURL The complete url(including port) of the X3DH key server.
*/
public native String get_x3dhServerUrl(String localDeviceId) throws LimeException;
/**
* @brief native function to process the X3DH server response
*
......
......@@ -287,9 +287,14 @@ namespace lime {
return lime::PeerDeviceStatus::fail;
}
template <typename Curve>
std::string Lime<Curve>::get_x3dhServerUrl() {
return m_X3DH_Server_URL;
}
/* instantiate Lime for C255 and C448 */
#ifdef EC25519_ENABLED
/* These extern templates are defines in lime_localStorage.cpp */
/* These extern templates are defined in lime_localStorage.cpp */
extern template bool Lime<C255>::create_user();
extern template void Lime<C255>::get_SelfIdentityKey();
extern template void Lime<C255>::X3DH_generate_SPk(X<C255, lime::Xtype::publicKey> &publicSPk, DSA<C255, DSAtype::signature> &SPk_sig, uint32_t &SPk_id, const bool load);
......@@ -300,6 +305,7 @@ namespace lime {
extern template bool Lime<C255>::is_currentSPk_valid(void);
extern template void Lime<C255>::X3DH_get_OPk(uint32_t OPk_id, Xpair<C255> &SPk);
extern template void Lime<C255>::X3DH_updateOPkStatus(const std::vector<uint32_t> &OPkIds);
extern template void Lime<C255>::set_x3dhServerUrl(const std::string &x3dhServerUrl);
/* These extern templates are defined in lime_x3dh.cpp*/
extern template void Lime<C255>::X3DH_init_sender_session(const std::vector<X3DH_peerBundle<C255>> &peerBundle);
extern template std::shared_ptr<DR<C255>> Lime<C255>::X3DH_init_receiver_session(const std::vector<uint8_t> X3DH_initMessage, const std::string &peerDeviceId);
......@@ -312,7 +318,7 @@ namespace lime {
#endif
#ifdef EC448_ENABLED
/* These extern templates are defines in lime_localStorage.cpp */
/* These extern templates are defined in lime_localStorage.cpp */
extern template bool Lime<C448>::create_user();
extern template void Lime<C448>::get_SelfIdentityKey();
extern template void Lime<C448>::X3DH_generate_SPk(X<C448, lime::Xtype::publicKey> &publicSPk, DSA<C448, DSAtype::signature> &SPk_sig, uint32_t &SPk_id, const bool load);
......@@ -323,6 +329,7 @@ namespace lime {
extern template bool Lime<C448>::is_currentSPk_valid(void);
extern template void Lime<C448>::X3DH_get_OPk(uint32_t OPk_id, Xpair<C448> &SPk);
extern template void Lime<C448>::X3DH_updateOPkStatus(const std::vector<uint32_t> &OPkIds);
extern template void Lime<C448>::set_x3dhServerUrl(const std::string &x3dhServerUrl);
/* These extern templates are defined in lime_x3dh.cpp*/
extern template void Lime<C448>::X3DH_init_sender_session(const std::vector<X3DH_peerBundle<C448>> &peerBundle);
extern template std::shared_ptr<DR<C448>> Lime<C448>::X3DH_init_receiver_session(const std::vector<uint8_t> X3DH_initMessage, const std::string &peerDeviceId);
......
......@@ -364,6 +364,7 @@ int lime_ffi_get_selfIdentityKey(lime_manager_t manager, const char *localDevice
*IkSize = l_Ik.size();
return LIME_FFI_SUCCESS;
} else {
*IkSize = 0;
return LIME_FFI_OUTPUT_BUFFER_TOO_SMALL;
}
} catch (BctbxException const &e) {
......@@ -419,4 +420,42 @@ int lime_ffi_update(lime_manager_t manager, const lime_ffi_Callback callback, v
return LIME_FFI_SUCCESS;
}
int lime_ffi_set_x3dhServerUrl(lime_manager_t manager, const char *localDeviceId, const char *x3dhServerUrl) {
try {
manager->context->set_x3dhServerUrl(std::string(localDeviceId), std::string(x3dhServerUrl));
} catch (BctbxException const &e) {
LIME_LOGE<<"FFI failed during set X3DH server Url: "<<e.str();
return LIME_FFI_INTERNAL_ERROR;
} catch (exception const &e) { // catch anything
LIME_LOGE<<"FFI failed during set X3DH server Url: "<<e.what();
return LIME_FFI_INTERNAL_ERROR;
}
return LIME_FFI_SUCCESS;
}
int lime_ffi_get_x3dhServerUrl(lime_manager_t manager, const char *localDeviceId, char *x3dhServerUrl, size_t *x3dhServerUrlSize) {
std::string url{};
try {
url = manager->context->get_x3dhServerUrl(std::string(localDeviceId));
} catch (BctbxException const &e) {
LIME_LOGE<<"FFI failed during get X3DH server Url: "<<e.str();
return LIME_FFI_INTERNAL_ERROR;
} catch (exception const &e) { // catch anything
LIME_LOGE<<"FFI failed during get X3DH server Url: "<<e.what();
return LIME_FFI_INTERNAL_ERROR;
}
// check the output buffer is large enough
if (url.size() >= *x3dhServerUrlSize) { // >= as we need room for the NULL termination
*x3dhServerUrlSize = 0;
return LIME_FFI_OUTPUT_BUFFER_TOO_SMALL;
} else {
std::copy_n(url.begin(), url.size(), x3dhServerUrl);
x3dhServerUrl[url.size()] = '\0';
*x3dhServerUrlSize = url.size();
return LIME_FFI_SUCCESS;
}
}
} // extern "C"
......@@ -109,6 +109,8 @@ namespace lime {
void get_Ik(std::vector<uint8_t> &Ik) override;
void encrypt(std::shared_ptr<const std::string> recipientUserId, std::shared_ptr<std::vector<RecipientData>> recipients, std::shared_ptr<const std::vector<uint8_t>> plainMessage, const lime::EncryptionPolicy encryptionPolicy, std::shared_ptr<std::vector<uint8_t>> cipherMessage, const limeCallback &callback) override;
lime::PeerDeviceStatus decrypt(const std::string &recipientUserId, const std::string &senderDeviceId, const std::vector<uint8_t> &DRmessage, const std::vector<uint8_t> &cipherMessage, std::vector<uint8_t> &plainMessage) override;
void set_x3dhServerUrl(const std::string &x3dhServerUrl) override;
std::string get_x3dhServerUrl() override;
};
/**
......
......@@ -456,6 +456,28 @@ struct jLimeManager {
void delete_peerDevice(jni::JNIEnv &env, const jni::String &jpeerDeviceId) {
m_manager->delete_peerDevice(jni::Make<std::string>(env, jpeerDeviceId));
}
void set_x3dhServerUrl(jni::JNIEnv &env, const jni::String &jlocalDeviceId, const jni::String &jx3dhServerUrl) {
try {
m_manager->set_x3dhServerUrl(jni::Make<std::string>(env, jlocalDeviceId), jni::Make<std::string>(env, jx3dhServerUrl));
} catch (BctbxException const &e) {
ThrowJavaLimeException(env, e.str());
} catch (std::exception const &e) { // catch anything
ThrowJavaLimeException(env, e.what());
}
}
jni::Local<jni::String> get_x3dhServerUrl(jni::JNIEnv &env, const jni::String &jlocalDeviceId) {
std::string url{};
try {
url = m_manager->get_x3dhServerUrl(jni::Make<std::string>(env, jlocalDeviceId));
} catch (BctbxException const &e) {
ThrowJavaLimeException(env, e.str());
} catch (std::exception const &e) { // catch anything
ThrowJavaLimeException(env, e.what());
}
return jni::Make<jni::String>(env, url);
}
};
#define METHOD(MethodPtr, name) jni::MakeNativePeerMethod<decltype(MethodPtr), (MethodPtr)>(name)
......@@ -473,7 +495,9 @@ jni::RegisterNativePeer<jLimeManager>(env, jni::Class<jLimeManager>::Find(env),
METHOD(&jLimeManager::set_peerDeviceStatus_Ik, "n_set_peerDeviceStatus_Ik"),
METHOD(&jLimeManager::set_peerDeviceStatus, "n_set_peerDeviceStatus"),
METHOD(&jLimeManager::get_peerDeviceStatus, "n_get_peerDeviceStatus"),
METHOD(&jLimeManager::delete_peerDevice, "delete_peerDevice")
METHOD(&jLimeManager::delete_peerDevice, "delete_peerDevice"),
METHOD(&jLimeManager::set_x3dhServerUrl, "set_x3dhServerUrl"),
METHOD(&jLimeManager::get_x3dhServerUrl, "get_x3dhServerUrl")
);
// bind the process_response to the static java LimeManager.process_response method
......
......@@ -142,6 +142,20 @@ namespace lime {
*/
virtual void get_Ik(std::vector<uint8_t> &Ik) = 0;
/**
* @brief Set the X3DH key server URL for this identified user
*
* @param[in] x3dhServerUrl The complete url(including port) of the X3DH key server
*/
virtual void set_x3dhServerUrl(const std::string &x3dhServerUrl) = 0;
/**
* @brief Get the X3DH key server URL for this identified user
*
* @return The complete url(including port) of the X3DH key server
*/
virtual std::string get_x3dhServerUrl() = 0;
virtual ~LimeGeneric() {};
};
......
......@@ -1258,6 +1258,23 @@ void Lime<Curve>::X3DH_updateOPkStatus(const std::vector<uint32_t> &OPkIds) {
m_localStorage->sql << "DELETE FROM X3DH_OPK WHERE Uid = :Uid AND Status = 0 AND timeStamp < date('now', '-"<<lime::settings::OPk_limboTime_days<<" day');", use(m_db_Uid);
}
template <typename Curve>
void Lime<Curve>::set_x3dhServerUrl(const std::string &x3dhServerUrl) {
transaction tr(m_localStorage->sql);
// update in DB, do not check presence as we're called after a load_user who already ensure that
try {
m_localStorage->sql<<"UPDATE lime_LocalUsers SET server = :server WHERE UserId = :userId;", use(x3dhServerUrl), use(m_selfDeviceId);
} catch (exception const &e) {
tr.rollback();
throw BCTBX_EXCEPTION << "Cannot set the X3DH server url for user "<<m_selfDeviceId<<". DB backend says: "<<e.what();
}
// update in the Lime object
m_X3DH_Server_URL = x3dhServerUrl;
tr.commit();
}
/* template instanciations for Curves 25519 and 448 */
#ifdef EC25519_ENABLED
template bool Lime<C255>::create_user();
......@@ -1271,6 +1288,7 @@ void Lime<Curve>::X3DH_updateOPkStatus(const std::vector<uint32_t> &OPkIds) {
template bool Lime<C255>::is_currentSPk_valid(void);
template void Lime<C255>::X3DH_get_OPk(uint32_t OPk_id, Xpair<C255> &SPk);
template void Lime<C255>::X3DH_updateOPkStatus(const std::vector<uint32_t> &OPkIds);
template void Lime<C255>::set_x3dhServerUrl(const std::string &x3dhServerUrl);
#endif
#ifdef EC448_ENABLED
......@@ -1285,6 +1303,7 @@ void Lime<Curve>::X3DH_updateOPkStatus(const std::vector<uint32_t> &OPkIds) {
template bool Lime<C448>::is_currentSPk_valid(void);
template void Lime<C448>::X3DH_get_OPk(uint32_t OPk_id, Xpair<C448> &SPk);
template void Lime<C448>::X3DH_updateOPkStatus(const std::vector<uint32_t> &OPkIds);
template void Lime<C448>::set_x3dhServerUrl(const std::string &x3dhServerUrl);
#endif
......
......@@ -203,5 +203,22 @@ namespace lime {
localStorage->delete_peerDevice(peerDeviceId);
}
void LimeManager::set_x3dhServerUrl(const std::string &localDeviceId, const std::string &x3dhServerUrl) {
// load user (generate an exception if not found, let it flow up)
std::shared_ptr<LimeGeneric> user;
LimeManager::load_user(user, localDeviceId);
user->set_x3dhServerUrl(x3dhServerUrl);
}
std::string LimeManager::get_x3dhServerUrl(const std::string &localDeviceId) {
// load user (generate an exception if not found, let it flow up)
std::shared_ptr<LimeGeneric> user;
LimeManager::load_user(user, localDeviceId);
return user->get_x3dhServerUrl();
}
} // namespace lime
......@@ -166,6 +166,11 @@ namespace lime {
*
* @param[in,out] message an empty buffer to store the message
* @param[in] Ik Self public identity key (formatted for signature algorithm)
* @param[in] SPk public signed pre-key (ECDH format)
* @param[in] Sig SPk signed using Ik
* @param[in] SPk_id SPk Id in local storage
* @param[in] OPks Vector of one time pre-keys
* @param[in] OPk_ids Ids of the OPk hold by previous vector(in matching indexes)
*/
template <typename Curve>
void buildMessage_registerUser(std::vector<uint8_t> &message, const DSA<Curve, lime::DSAtype::publicKey> &Ik, const X<Curve, lime::Xtype::publicKey> &SPk, const DSA<Curve, lime::DSAtype::signature> &Sig, const uint32_t SPk_id, const std::vector<X<Curve, lime::Xtype::publicKey>> &OPks, const std::vector<uint32_t> &OPk_ids) noexcept {
......
......@@ -57,13 +57,27 @@ public class LimeLimeTester {
// Create alice lime managers
LimeManager aliceManager = new LimeManager(aliceDbFilename, postObj);
// Create random device id for alice and bob
// Create random device id for alice
String AliceDeviceId = "alice."+UUID.randomUUID().toString();
try {
aliceManager.create_user(AliceDeviceId, x3dhServerUrl, curveId, 10, statusCallback);
expected_success+= 1;
assert (statusCallback.wait_for_success(expected_success));
// Get alice x3dh server url
assert(aliceManager.get_x3dhServerUrl(AliceDeviceId).equals(x3dhServerUrl));
// Set the X3DH URL server to something else and check it worked
aliceManager.set_x3dhServerUrl(AliceDeviceId, "https://testing.testing:12345");
assert(aliceManager.get_x3dhServerUrl(AliceDeviceId).equals("https://testing.testing:12345"));
// Force a reload of data from local storage just to be sure the modification was perform correctly
aliceManager.nativeDestructor();
aliceManager = null;
aliceManager = new LimeManager(aliceDbFilename, postObj);
assert(aliceManager.get_x3dhServerUrl(AliceDeviceId).equals("https://testing.testing:12345"));
// Set it back to the regular one to be able to complete the test
aliceManager.set_x3dhServerUrl(AliceDeviceId, x3dhServerUrl);
}
catch (LimeException e) {
assert(false):"Got an unexpected exception during Lime user management test: "+e.getMessage();
......
......@@ -550,6 +550,8 @@ static void freeRecipientBuffers(lime_ffi_RecipientData_t *recipients, size_t re
* if continuousSession is set to false, delete and recreate LimeManager before each new operation to force relying on local Storage
*/
static void ffi_basic_test(const enum lime_ffi_CurveId curve, const char *dbBaseFilename, const char *x3dh_server_url, const uint8_t continuousSession) {
char serverUrl[256]; // needed to test set/get x3dh url server functionnality
size_t serverUrlSize = sizeof(serverUrl);
/* users databases names: baseFilename.<alice/bob>.<curve id>.sqlite3 */
char dbFilenameAlice[512];
char dbFilenameBob1[512];
......@@ -597,6 +599,34 @@ static void ffi_basic_test(const enum lime_ffi_CurveId curve, const char *dbBase
managerClean(&bobManager2, dbFilenameBob2);
}
/*** Set/Get X3DH server URL functionality checks ***/
/* Get alice x3dh server url */
serverUrlSize = sizeof(serverUrl);
lime_ffi_get_x3dhServerUrl(aliceManager, aliceDeviceId, serverUrl, &serverUrlSize);
BC_ASSERT_TRUE(strcmp(serverUrl, x3dh_server_url) == 0);
serverUrl[0]='\0'; // clean the serverUrl char buffer
// Set the X3DH URL server to something else and check it worked
lime_ffi_set_x3dhServerUrl(aliceManager, aliceDeviceId, "https://testing.testing:12345");
serverUrlSize = sizeof(serverUrl);
lime_ffi_get_x3dhServerUrl(aliceManager, aliceDeviceId, serverUrl, &serverUrlSize);
BC_ASSERT_TRUE(strcmp(serverUrl,"https://testing.testing:12345") == 0);
// Force a reload of data from local storage just to be sure the modification was perform correctly
managerClean(&aliceManager, dbFilenameAlice);
serverUrl[0]='\0';
serverUrlSize = sizeof(serverUrl);
lime_ffi_get_x3dhServerUrl(aliceManager, aliceDeviceId, serverUrl, &serverUrlSize);
BC_ASSERT_TRUE(strcmp(serverUrl,"https://testing.testing:12345") == 0);
// Set it back to the regular one to be able to complete the test
lime_ffi_set_x3dhServerUrl(aliceManager, aliceDeviceId, x3dh_server_url);
/* respawn managers from cache if requested */
if (!continuousSession) {
managerClean(&aliceManager, dbFilenameAlice);
managerClean(&bobManager1, dbFilenameBob1);
managerClean(&bobManager2, dbFilenameBob2);
}
/*** encrypt 2 messages to Bob at the same time, do not wait for one to be finished to encrypt the second one ***/
/* prepare the data: alloc memory for the recipients data */
......
......@@ -3112,6 +3112,19 @@ static void user_management_test(const lime::CurveId curve, const std::string &d
/* load alice from from DB */
auto alice = load_LimeUser(dbFilenameAlice, *aliceDeviceName, X3DHServerPost);
/* no need to wait here, it shall load alice immediately */
// Get alice x3dh server url
BC_ASSERT_TRUE(Manager->get_x3dhServerUrl(*aliceDeviceName) == x3dh_server_url);
// Set the X3DH URL server to something else and check it worked
Manager->set_x3dhServerUrl(*aliceDeviceName, "https://testing.testing:12345");
BC_ASSERT_TRUE(Manager->get_x3dhServerUrl(*aliceDeviceName) == "https://testing.testing:12345");
// Force a reload of data from local storage just to be sure the modification was perform correctly
Manager = nullptr;
Manager = std::unique_ptr<LimeManager>(new LimeManager(dbFilenameAlice, X3DHServerPost));
BC_ASSERT_TRUE(Manager->get_x3dhServerUrl(*aliceDeviceName) == "https://testing.testing:12345");
// Set it back to the regular one to be able to complete the test
Manager->set_x3dhServerUrl(*aliceDeviceName, x3dh_server_url);
} catch (BctbxException &e) {
LIME_LOGE << e;
BC_FAIL("");
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment