Commit 94339493 authored by Rouslan Solomakhin's avatar Rouslan Solomakhin Committed by Commit Bot

[Web Payment] Return challenge for secure payment confirmation.

Before this patch, secure payment confirmation method would return the
signature of the challenge that was generated on the browser using the
JSON stringification. In order to verify the signature, an issuer would
have to stringify JSON as well, but that stringification could be
different from how Chrome did it, thus resulting in a rejected
transaction.

This patch returns Chrome's stringified JSON challenge along with the
payment response for secure payment confirmation. The tests to verify
this behavior revealed that secure payment confirmation app ignored
price changes in modifiers and show promises, so that bug is fixed here
as well.

After this patch, the issuer does not need to generate the JSON string
itself, but instead can verify the values in the JSON string generated
and returned by Chrome.

Bug: 1123054, 1124747
Change-Id: I0fa8974411635e406a9d2f253b7061b892e06949
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/2388271
Commit-Queue: Danyao Wang <danyao@chromium.org>
Reviewed-by: default avatarDanyao Wang <danyao@chromium.org>
Reviewed-by: default avatarAdam Langley <agl@chromium.org>
Cr-Commit-Position: refs/heads/master@{#813343}
parent 239e4113
......@@ -280,6 +280,7 @@ void PaymentRequest::Show(bool is_user_gesture, bool wait_for_updated_details) {
// This method does not block.
spec_->StartWaitingForUpdateWith(
PaymentRequestSpec::UpdateReason::INITIAL_PAYMENT_DETAILS);
spec_->AddInitializationObserver(this);
} else {
DCHECK(spec_->details().total);
journey_logger_.RecordTransactionAmount(
......@@ -567,8 +568,10 @@ bool PaymentRequest::ChangeShippingAddress(
void PaymentRequest::AreRequestedMethodsSupportedCallback(
bool methods_supported,
const std::string& error_message) {
if (is_show_called_ && observer_for_testing_)
if (is_show_called_ && spec_ && spec_->IsInitialized() &&
observer_for_testing_) {
observer_for_testing_->OnAppListReady(weak_ptr_factory_.GetWeakPtr());
}
if (methods_supported) {
if (SatisfiesSkipUIConstraints())
......@@ -593,6 +596,16 @@ base::WeakPtr<PaymentRequest> PaymentRequest::GetWeakPtr() {
return weak_ptr_factory_.GetWeakPtr();
}
void PaymentRequest::OnInitialized(InitializationTask* initialization_task) {
DCHECK_EQ(spec_.get(), initialization_task);
DCHECK_EQ(PaymentRequestSpec::UpdateReason::INITIAL_PAYMENT_DETAILS,
spec_->current_update_reason());
if (is_show_called_ && state_ && state_->is_get_all_apps_finished() &&
observer_for_testing_) {
observer_for_testing_->OnAppListReady(weak_ptr_factory_.GetWeakPtr());
}
}
bool PaymentRequest::IsInitialized() const {
return is_initialized_ && client_ && client_.is_bound() &&
receiver_.is_bound() && state_ && spec_;
......
......@@ -11,6 +11,7 @@
#include "base/macros.h"
#include "base/memory/weak_ptr.h"
#include "components/payments/content/developer_console_logger.h"
#include "components/payments/content/initialization_task.h"
#include "components/payments/content/payment_handler_host.h"
#include "components/payments/content/payment_request_display_manager.h"
#include "components/payments/content/payment_request_spec.h"
......@@ -43,7 +44,8 @@ class PaymentRequestWebContentsManager;
class PaymentRequest : public mojom::PaymentRequest,
public PaymentHandlerHost::Delegate,
public PaymentRequestSpec::Observer,
public PaymentRequestState::Delegate {
public PaymentRequestState::Delegate,
public InitializationTask::Observer {
public:
class ObserverForTest {
public:
......@@ -154,6 +156,9 @@ class PaymentRequest : public mojom::PaymentRequest,
base::WeakPtr<PaymentRequest> GetWeakPtr();
private:
// InitializationTask::Observer.
void OnInitialized(InitializationTask* initialization_task) override;
// Returns true after init() has been called and the mojo connection has been
// established. If the mojo connection gets later disconnected, this will
// returns false.
......
......@@ -18,6 +18,7 @@
#include "base/time/time.h"
#include "base/values.h"
#include "components/autofill/core/browser/payments/internal_authenticator.h"
#include "components/payments/content/payment_request_spec.h"
#include "components/payments/core/method_strings.h"
#include "components/payments/core/payer_data.h"
#include "content/public/browser/web_contents.h"
......@@ -45,12 +46,14 @@ static constexpr int kDefaultTimeoutMinutes = 3;
// },
// "networkData": "YW=",
// }
// where "networkData" is the base64 encoding of the |networkData| specified in
// the SecurePaymentConfirmationRequest.
// where "networkData" is the base64 encoding of the `networkData` specified in
// the SecurePaymentConfirmationRequest. Sets the `challenge` out-param value to
// this JSON string.
std::vector<uint8_t> GetSecurePaymentConfirmationChallenge(
const std::vector<uint8_t>& network_data,
const url::Origin& merchant_origin,
const mojom::PaymentCurrencyAmountPtr& amount) {
const mojom::PaymentCurrencyAmountPtr& amount,
std::string* challenge) {
base::Value total(base::Value::Type::DICTIONARY);
total.SetKey("currency", base::Value(amount->currency));
total.SetKey("value", base::Value(amount->value));
......@@ -65,14 +68,10 @@ std::vector<uint8_t> GetSecurePaymentConfirmationChallenge(
base::Value(base::Base64Encode(network_data)));
transaction_data.SetKey("merchantData", std::move(merchant_data));
// TODO(crbug.com/1123054): change to a more robust alternative that does not
// depend on the exact whitespace, escaping and ordering of the JSON
// serialization.
std::string json;
bool success = base::JSONWriter::Write(transaction_data, &json);
bool success = base::JSONWriter::Write(transaction_data, challenge);
DCHECK(success) << "Failed to write JSON for " << transaction_data;
std::string sha256_hash = crypto::SHA256HashString(json);
std::string sha256_hash = crypto::SHA256HashString(*challenge);
std::vector<uint8_t> output_bytes(sha256_hash.begin(), sha256_hash.end());
return output_bytes;
}
......@@ -86,7 +85,7 @@ SecurePaymentConfirmationApp::SecurePaymentConfirmationApp(
const base::string16& label,
std::vector<uint8_t> credential_id,
const url::Origin& merchant_origin,
const mojom::PaymentCurrencyAmountPtr& total,
base::WeakPtr<PaymentRequestSpec> spec,
mojom::SecurePaymentConfirmationRequestPtr request,
std::unique_ptr<autofill::InternalAuthenticator> authenticator)
: PaymentApp(/*icon_resource_id=*/0, PaymentApp::Type::INTERNAL),
......@@ -99,7 +98,7 @@ SecurePaymentConfirmationApp::SecurePaymentConfirmationApp(
credential_id_(std::move(credential_id)),
encoded_credential_id_(base::Base64Encode(credential_id_)),
merchant_origin_(merchant_origin),
total_(total.Clone()),
spec_(spec),
request_(std::move(request)),
authenticator_(std::move(authenticator)) {
DCHECK_EQ(web_contents_to_observe->GetMainFrame(),
......@@ -112,9 +111,11 @@ SecurePaymentConfirmationApp::SecurePaymentConfirmationApp(
SecurePaymentConfirmationApp::~SecurePaymentConfirmationApp() = default;
void SecurePaymentConfirmationApp::InvokePaymentApp(Delegate* delegate) {
if (!authenticator_)
if (!authenticator_ || !spec_)
return;
DCHECK(spec_->IsInitialized());
auto options = blink::mojom::PublicKeyCredentialRequestOptions::New();
options->relying_party_id = effective_relying_party_identity_;
options->timeout = request_->timeout.has_value()
......@@ -141,7 +142,8 @@ void SecurePaymentConfirmationApp::InvokePaymentApp(Delegate* delegate) {
// Create a new challenge that is a hash of the transaction data.
options->challenge = GetSecurePaymentConfirmationChallenge(
request_->network_data, merchant_origin_, total_);
request_->network_data, merchant_origin_,
spec_->GetTotal(/*selected_app=*/this)->amount, &challenge_);
// We are nullifying the security check by design, and the origin that created
// the credential isn't saved anywhere.
......@@ -298,6 +300,7 @@ void SecurePaymentConfirmationApp::OnGetAssertion(
base::DictionaryValue json;
json.Set("info", std::move(info_json));
json.SetString("challenge", challenge_);
json.SetString("signature", base::Base64Encode(response->signature));
if (response->user_handle.has_value()) {
json.SetString("user_handle",
......
......@@ -33,6 +33,8 @@ class WebContents;
namespace payments {
class PaymentRequestSpec;
class SecurePaymentConfirmationApp : public PaymentApp,
public content::WebContentsObserver {
public:
......@@ -45,7 +47,7 @@ class SecurePaymentConfirmationApp : public PaymentApp,
const base::string16& label,
std::vector<uint8_t> credential_id,
const url::Origin& merchant_origin,
const mojom::PaymentCurrencyAmountPtr& total,
base::WeakPtr<PaymentRequestSpec> spec,
mojom::SecurePaymentConfirmationRequestPtr request,
std::unique_ptr<autofill::InternalAuthenticator> authenticator);
~SecurePaymentConfirmationApp() override;
......@@ -103,9 +105,10 @@ class SecurePaymentConfirmationApp : public PaymentApp,
const std::vector<uint8_t> credential_id_;
const std::string encoded_credential_id_;
const url::Origin merchant_origin_;
const mojom::PaymentCurrencyAmountPtr total_;
const base::WeakPtr<PaymentRequestSpec> spec_;
const mojom::SecurePaymentConfirmationRequestPtr request_;
std::unique_ptr<autofill::InternalAuthenticator> authenticator_;
std::string challenge_;
base::WeakPtrFactory<SecurePaymentConfirmationApp> weak_ptr_factory_{this};
};
......
......@@ -231,7 +231,7 @@ void SecurePaymentConfirmationAppFactory::OnAppIconDecoded(
std::move(icon), instrument->label,
std::move(instrument->credential_id),
url::Origin::Create(request->delegate->GetTopOrigin()),
request->delegate->GetSpec()->details().total->amount,
request->delegate->GetSpec()->AsWeakPtr(),
std::move(request->mojo_request), std::move(request->authenticator)));
request->delegate->OnDoneCreatingPaymentApps();
......
......@@ -5,17 +5,20 @@
#include "components/payments/content/secure_payment_confirmation_app.h"
#include <memory>
#include <utility>
#include "base/base64.h"
#include "base/strings/string_piece.h"
#include "base/strings/utf_string_conversions.h"
#include "components/autofill/core/browser/payments/internal_authenticator.h"
#include "components/payments/content/payment_request_spec.h"
#include "content/public/browser/web_contents.h"
#include "content/public/test/browser_task_environment.h"
#include "content/public/test/test_browser_context.h"
#include "content/public/test/test_web_contents_factory.h"
#include "testing/gmock/include/gmock/gmock.h"
#include "testing/gtest/include/gtest/gtest.h"
#include "third_party/blink/public/mojom/payments/payment_request.mojom.h"
#include "third_party/blink/public/mojom/webauthn/authenticator.mojom.h"
#include "url/origin.h"
......@@ -69,10 +72,16 @@ class MockAuthenticator : public autofill::InternalAuthenticator {
class SecurePaymentConfirmationAppTest : public testing::Test {
protected:
SecurePaymentConfirmationAppTest()
: label_(base::ASCIIToUTF16("test instrument")),
total_(mojom::PaymentCurrencyAmount::New()) {
total_->currency = "USD";
total_->value = "1.25";
: label_(base::ASCIIToUTF16("test instrument")) {
mojom::PaymentDetailsPtr details = mojom::PaymentDetails::New();
details->total = mojom::PaymentItem::New();
details->total->amount = mojom::PaymentCurrencyAmount::New();
details->total->amount->currency = "USD";
details->total->amount->value = "1.25";
std::vector<mojom::PaymentMethodDataPtr> method_data;
spec_ = std::make_unique<PaymentRequestSpec>(
mojom::PaymentOptions::New(), std::move(details),
std::move(method_data), /*observer=*/nullptr, /*app_locale=*/"en-US");
}
void SetUp() override {
......@@ -88,7 +97,7 @@ class SecurePaymentConfirmationAppTest : public testing::Test {
}
base::string16 label_;
mojom::PaymentCurrencyAmountPtr total_;
std::unique_ptr<PaymentRequestSpec> spec_;
std::string network_data_bytes_;
std::string credential_id_bytes_;
};
......@@ -103,8 +112,8 @@ TEST_F(SecurePaymentConfirmationAppTest, Smoke) {
SecurePaymentConfirmationApp app(
web_contents, "effective_rp.example",
/*icon=*/std::make_unique<SkBitmap>(), label_, std::move(credential_id),
url::Origin::Create(GURL("https://merchant.example")), total_,
/*Icon=*/std::make_unique<SkBitmap>(), label_, std::move(credential_id),
url::Origin::Create(GURL("https://merchant.example")), spec_->AsWeakPtr(),
MakeRequest(), std::move(authenticator));
EXPECT_CALL(*mock_authenticator, SetEffectiveOrigin(Eq(url::Origin::Create(
......
......@@ -54,18 +54,3 @@ async function canMakePaymentForMethodDataTwice(methodData) { // eslint-disable-
return e.message;
}
}
/**
* Creates a PaymentRequest with |methodData| and checks hasEnrolledInstrument.
* @param {object} methodData - The payment method data to build the request.
* @return {string} - 'true', 'false', or error message on failure.
*/
async function hasEnrolledInstrumentForMethodData(methodData) { // eslint-disable-line no-unused-vars, max-len
try {
const request = new PaymentRequest(methodData, kDetails);
const result = await request.hasEnrolledInstrument();
return result ? 'true' : 'false';
} catch (e) {
return e.message;
}
}
<!DOCTYPE html>
<!--
Copyright 2020 The Chromium Authors. All rights reserved.
Use of this source code is governed by a BSD-style license that can be
found in the LICENSE file.
-->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1">
<title>Get Challenge</title>
</head>
<body>
<script src="get_challenge.js"></script>
</body>
</html>
/*
* Copyright 2020 The Chromium Authors. All rights reserved.
* Use of this source code is governed by a BSD-style license that can be
* found in the LICENSE file.
*/
const kPaymentMethodIdentifier = 'secure-payment-confirmation';
/**
* Returns the challenge generated by the secure payment confirmation app.
* @param {string} credentialId - The base64 encoded identifier of the
* credential to use for payment.
* @param {string} totalAmount - The total amount to be charged.
* @return {Promise<object>} - Either the challenge string or an error message.
*/
async function getChallenge(credentialId, totalAmount) { // eslint-disable-line no-unused-vars, max-len
try {
const request = createPaymentRequest(credentialId, totalAmount, false, '');
const response = await request.show();
await response.complete();
return response.details.challenge;
} catch (e) {
return e.message;
}
}
/**
* Returns the challenge generated by the secure payment confirmation app with a
* modified total amount.
* @param {string} credentialId - The base64 encoded identifier of the
* credential to use for payment.
* @param {string} modifiedTotal - The total amount to be charged in the
* modifier.
* @return {Promise<object>} - Either the challenge string or an error message.
*/
async function getChallengeWithModifier(credentialId, modifiedTotal) { // eslint-disable-line no-unused-vars, max-len
try {
const request = createPaymentRequest(
credentialId, '0', true, modifiedTotal);
const response = await request.show();
await response.complete();
return response.details.challenge;
} catch (e) {
return e.message;
}
}
/**
* Returns the challenge generated by the secure payment confirmation app.
* Passes a promise into PaymentRequest.show() that resolves with the finalized
* price after 0.5 seconds.
* @param {string} credentialId - The base64 encoded identifier of the
* credential to use for payment.
* @param {string} initialAmount - The initial, not yet final, amount to be
* charged.
* @param {string} finalizedAmount - The finalized amount to be charged.
* @return {Promise<object>} - Either the challenge string or an error message.
*/
async function getChallengeWithShowPromise(credentialId, initialAmount, finalizedAmount) { // eslint-disable-line no-unused-vars, max-len
try {
const request = createPaymentRequest(
credentialId, initialAmount, false, '');
const response = await request.show(new Promise((resolve) => {
window.setTimeout(() => {
resolve(createDetails(finalizedAmount, false, ''));
}, 500); // 0.5 seconds.
}));
await response.complete();
return response.details.challenge;
} catch (e) {
return e.message;
}
}
/**
* Returns the challenge generated by the secure payment confirmation app.
* Passes a promise into PaymentRequest.show() that resolves with the finalized
* and modified price after 0.5 seconds.
* @param {string} credentialId - The base64 encoded identifier of the
* credential to use for payment.
* @param {string} finalizedModifierAmount - The finalized amount to be charged.
* @return {Promise<object>} - Either the challenge string or an error message.
*/
async function getChallengeWithModifierAndShowPromise(credentialId, finalizedModifierAmount) { // eslint-disable-line no-unused-vars, max-len
try {
const request = createPaymentRequest(credentialId, '0', false, '');
const response = await request.show(new Promise((resolve) => {
window.setTimeout(() => {
resolve(createDetails('0', true, finalizedModifierAmount));
}, 500); // 0.5 seconds.
}));
await response.complete();
return response.details.challenge;
} catch (e) {
return e.message;
}
}
/**
* Creates a PaymentRequest object for secure payment confirmation method.
* @param {string} credentialId - The base64 encoded identifier of the
* credential to use for payment.
* @param {string} totalAmount - The total amount to be charged.
* @param {bool} withModifier - Whether modifier should be added.
* @param {string} modifierAmount - The modifier amount, optional.
* @return {PaymentRequest} - A PaymentRequest object.
*/
function createPaymentRequest(
credentialId, totalAmount, withModifier, modifierAmount) {
return new PaymentRequest(
[{supportedMethods: kPaymentMethodIdentifier,
data: {
action: 'authenticate',
credentialIds: [Uint8Array.from(atob(credentialId),
(b) => b.charCodeAt(0))],
networkData: new TextEncoder().encode('hello world'),
timeout: 6000,
fallbackUrl: 'https://fallback.example/url',
},
}],
createDetails(totalAmount, withModifier, modifierAmount));
}
/**
* Creates the payment details to be the second parameter of PaymentRequest
* constructor.
* @param {string} totalAmount - The total amount.
* @param {bool} withModifier - Whether modifier should be added.
* @param {string} modifierAmount - The modifier amount, optional.
* @return {PaymentDetails} - The payment details with the given total amount.
*/
function createDetails(totalAmount, withModifier, modifierAmount) {
let result = {
total: {label: 'TEST', amount: {currency: 'USD', value: totalAmount}},
};
if (withModifier) {
result.modifiers = [{
supportedMethods: kPaymentMethodIdentifier,
total: {
label: 'MODIFIER TEST',
amount: {currency: 'USD', value: modifierAmount}},
}];
}
return result;
}
......@@ -10,9 +10,18 @@
* @return {string} - 'true', 'false', or error message on failure.
*/
async function hasEnrolledInstrument(method) { // eslint-disable-line no-unused-vars, max-len
return hasEnrolledInstrumentForMethodData([{supportedMethods: method}]);
}
/**
* Creates a PaymentRequest with `methodData` and checks hasEnrolledInstrument.
* @param {object} methodData - The payment method data to build the request.
* @return {string} - 'true', 'false', or error message on failure.
*/
async function hasEnrolledInstrumentForMethodData(methodData) {
try {
const request = new PaymentRequest(
[{supportedMethods: method}],
methodData,
{total: {label: 'TEST', amount: {currency: 'USD', value: '0.01'}}});
const result = await request.hasEnrolledInstrument();
return result ? 'true' : 'false';
......
<!DOCTYPE html>
<!--
Copyright 2020 The Chromium Authors. All rights reserved.
Use of this source code is governed by a BSD-style license that can be
found in the LICENSE file.
-->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1">
<title>Payment Handler Status</title>
</head>
<body>
<script src="payment_handler_status.js"></script>
<script src="can_make_payment_checker.js"></script>
<script src="has_enrolled_instrument_checker.js"></script>
<script src="secure_payment_confirmation.js"></script>
</body>
</html>
/*
* Copyright 2020 The Chromium Authors. All rights reserved.
* Use of this source code is governed by a BSD-style license that can be
* found in the LICENSE file.
*/
/**
* Creates and returns the first parameter to the PaymentRequest constructor for
* secure payment confirmation.
* @param {string} credentialIdentifier - An optional base64 encoded credential
* identifier. If not specified, then 'cred' is used instead.
* @return {Array<PaymentMethodData>} - Secure payment confirmation method data.
*/
function getTestMethodData(credentialIdentifier) {
return [{
supportedMethods: 'secure-payment-confirmation',
data: {
action: 'authenticate',
credentialIds: [Uint8Array.from(
(credentialIdentifier ? atob(credentialIdentifier) : 'cred'),
(c) => c.charCodeAt(0))],
networkData: Uint8Array.from('network_data', (c) => c.charCodeAt(0)),
timeout: 60000,
fallbackUrl: 'https://fallback.example/url',
}}];
}
/**
* Returns the status field of the response to a secure payment confirmation
* request.
* @param {string} credentialIdentifier - An optional base64 encoded credential
* identifier. If not specified, then 'cred' is used instead.
* @return {string} - The status field or error message.
*/
async function getSecurePaymentConfirmationStatus(credentialIdentifier) { // eslint-disable-line no-unused-vars, max-len
return getStatusForMethodData(getTestMethodData(credentialIdentifier));
}
/**
* Checks the result of canMakePayment() (ignoring its actual result) and then
* returns the status field of the response to a secure payment confirmation
* request.
* @return {string} - The status field or error message.
*/
async function getSecurePaymentConfirmationStatusAfterCanMakePayment() { // eslint-disable-line no-unused-vars, max-len
return getStatusForMethodDataAfterCanMakePayment(
getTestMethodData(), /* checkCanMakePaymentFirst = */true);
}
/**
* Checks whether secure payment confirmation can make payments.
* @return {string} - 'true', 'false', or error message on failure.
*/
async function securePaymentConfirmationCanMakePayment() { // eslint-disable-line no-unused-vars, max-len
return canMakePaymentForMethodData(getTestMethodData());
}
/**
* Creates a PaymentRequest for secure payment confirmation, checks
* canMakePayment twice, and returns the second value.
* @return {string} - 'true', 'false', or error message on failure.
*/
async function securePaymentConfirmationCanMakePaymentTwice() { // eslint-disable-line no-unused-vars, max-len
return canMakePaymentForMethodDataTwice(getTestMethodData());
}
/**
* Checks whether secure payment confirmation has enrolled instruments.
* @return {string} - 'true', 'false', or error message on failure.
*/
async function securePaymentConfirmationHasEnrolledInstrument() { // eslint-disable-line no-unused-vars, max-len
return hasEnrolledInstrumentForMethodData(getTestMethodData());
}
/**
* Creates a secure payment confirmation credential and returns "OK" on success.
* @param {string} icon - The URL of the icon for the credential.
* @return {string} - Either "OK" or an error string.
*/
async function createPaymentCredential(icon) { // eslint-disable-line no-unused-vars, max-len
try {
// Intentionally ignore the result.
await createAndReturnPaymentCredential(icon);
return 'OK';
} catch (e) {
return e.toString();
}
}
/**
* Creates a secure payment confirmation credential and returns its identifier.
* @param {string} icon - The URL of the icon for the credential.
* @return {string} - The base64 encoded identifier of the new credential.
*/
async function createCredentialAndReturnItsIdentifier(icon) { // eslint-disable-line no-unused-vars, max-len
const credential = await createAndReturnPaymentCredential(icon);
return btoa(String.fromCharCode(...new Uint8Array(credential.rawId)));
}
/**
* Creates and returns a secure payment confirmation credential.
* @param {string} icon - The URL of the icon for the credential.
* @return {PaymentCredential} - The new credential.
*/
async function createAndReturnPaymentCredential(icon) {
const paymentInstrument = {
displayName: 'display_name_for_instrument',
icon,
};
const publicKeyRP = {
id: 'a.com',
name: 'Acme',
};
const publicKeyParameters = [{
type: 'public-key',
alg: -7,
}];
const payment = {
rp: publicKeyRP,
instrument: paymentInstrument,
challenge: new TextEncoder().encode('climb a mountain'),
pubKeyCredParams: publicKeyParameters,
};
return navigator.credentials.create({payment});
}
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