Unverified Commit 8890e1b2 authored by Raúl Kripalani's avatar Raúl Kripalani Committed by GitHub

Revert "update insecure transport to plaintext/2.0.0 (#37)" (#38)

This reverts commit b5729d89.
parent 1d45af25
......@@ -278,44 +278,27 @@ func UnmarshalPublicKey(data []byte) (PubKey, error) {
if err != nil {
return nil, err
}
return PublicKeyFromProto(*pmes)
}
// PublicKeyFromProto converts an unserialized protobuf PublicKey message
// into its representative object. To convert a serialized public key,
// see UnmarshalPublicKey.
func PublicKeyFromProto(keyMessage pb.PublicKey) (PubKey, error) {
um, ok := PubKeyUnmarshallers[keyMessage.GetType()]
um, ok := PubKeyUnmarshallers[pmes.GetType()]
if !ok {
return nil, ErrBadKeyType
}
return um(keyMessage.GetData())
return um(pmes.GetData())
}
// MarshalPublicKey converts a public key object into a protobuf serialized
// public key
func MarshalPublicKey(k PubKey) ([]byte, error) {
pbmes, err := PublicKeyToProto(k)
if err != nil {
return nil, err
}
return proto.Marshal(pbmes)
}
// PublicKeyToProto converts a public key object into an unserialized protobuf
// PublicKey message.
func PublicKeyToProto(k PubKey) (*pb.PublicKey, error) {
pbmes := new(pb.PublicKey)
pbmes.Type = k.Type()
data, err := k.Raw()
if err != nil {
return nil, err
}
pbmes := new(pb.PublicKey)
pbmes.Type = k.Type()
pbmes.Data = data
return pbmes, nil
return proto.Marshal(pbmes)
}
// UnmarshalPrivateKey converts a protobuf serialized private key into its
......
......@@ -5,172 +5,64 @@ package insecure
import (
"context"
"fmt"
"github.com/libp2p/go-libp2p-core/network"
"net"
"github.com/libp2p/go-libp2p-core/peer"
"github.com/libp2p/go-libp2p-core/sec"
ggio "github.com/gogo/protobuf/io"
ci "github.com/libp2p/go-libp2p-core/crypto"
pb "github.com/libp2p/go-libp2p-core/sec/insecure/pb"
)
// ID is the multistream-select protocol ID that should be used when identifying
// this security transport.
const ID = "/plaintext/2.0.0"
const ID = "/plaintext/1.0.0"
// Transport is a no-op stream security transport. It provides no
// security and simply mocks the security methods. Identity methods
// return the local peer's ID and private key, and whatever the remote
// peer presents as their ID and public key.
// No authentication of the remote identity is performed.
// security and simply mocks the security and identity methods to
// return peer IDs known ahead of time.
type Transport struct {
id peer.ID
key ci.PrivKey
id peer.ID
}
// New constructs a new insecure transport.
func New(id peer.ID, key ci.PrivKey) *Transport {
func New(id peer.ID) *Transport {
return &Transport{
id: id,
key: key,
id: id,
}
}
// LocalPeer returns the transport's local peer ID.
// LocalPeer returns the transports local peer ID.
func (t *Transport) LocalPeer() peer.ID {
return t.id
}
// LocalPrivateKey returns the local private key.
// This key is used only for identity generation and provides no security.
// LocalPrivateKey returns nil. This transport is not secure.
func (t *Transport) LocalPrivateKey() ci.PrivKey {
return t.key
return nil
}
// SecureInbound *pretends to secure* an outbound connection to the given peer.
// It sends the local peer's ID and public key, and receives the same from the remote peer.
// No validation is performed as to the authenticity or ownership of the provided public key,
// and the key exchange provides no security.
//
// SecureInbound may fail if the remote peer sends an ID and public key that are inconsistent
// with each other, or if a network error occurs during the ID exchange.
func (t *Transport) SecureInbound(ctx context.Context, insecure net.Conn) (sec.SecureConn, error) {
conn := &Conn{
Conn: insecure,
local: t.id,
localPrivKey: t.key,
}
err := conn.runHandshakeSync(ctx)
if err != nil {
return nil, err
}
return conn, nil
return &Conn{
Conn: insecure,
local: t.id,
}, nil
}
// SecureOutbound *pretends to secure* an outbound connection to the given peer.
// It sends the local peer's ID and public key, and receives the same from the remote peer.
// No validation is performed as to the authenticity or ownership of the provided public key,
// and the key exchange provides no security.
//
// SecureOutbound may fail if the remote peer sends an ID and public key that are inconsistent
// with each other, or if the ID sent by the remote peer does not match the one dialed. It may
// also fail if a network error occurs during the ID exchange.
func (t *Transport) SecureOutbound(ctx context.Context, insecure net.Conn, p peer.ID) (sec.SecureConn, error) {
conn := &Conn{
Conn: insecure,
local: t.id,
localPrivKey: t.key,
}
err := conn.runHandshakeSync(ctx)
if err != nil {
return nil, err
}
if p != conn.remote {
return nil, fmt.Errorf("remote peer sent unexpected peer ID. expected=%s received=%s",
p, conn.remote)
}
return conn, nil
return &Conn{
Conn: insecure,
local: t.id,
remote: p,
}, nil
}
// Conn is the connection type returned by the insecure transport.
type Conn struct {
net.Conn
local peer.ID
remote peer.ID
localPrivKey ci.PrivKey
remotePubKey ci.PubKey
}
func makeExchangeMessage(privkey ci.PrivKey) (*pb.Exchange, error) {
pubkey, err := ci.PublicKeyToProto(privkey.GetPublic())
if err != nil {
return nil, err
}
id, err := peer.IDFromPrivateKey(privkey)
if err != nil {
return nil, err
}
return &pb.Exchange{
Id: []byte(id),
Pubkey: pubkey,
}, nil
}
func (ic *Conn) runHandshakeSync(ctx context.Context) error {
reader := ggio.NewDelimitedReader(ic.Conn, network.MessageSizeMax)
writer := ggio.NewDelimitedWriter(ic.Conn)
// Generate an Exchange message
msg, err := makeExchangeMessage(ic.localPrivKey)
if err != nil {
return err
}
// Send our Exchange and read theirs
err = writer.WriteMsg(msg)
if err != nil {
return err
}
remoteMsg := new(pb.Exchange)
err = reader.ReadMsg(remoteMsg)
if err != nil {
return err
}
// Pull remote ID and public key from message
remotePubkey, err := ci.PublicKeyFromProto(*remoteMsg.Pubkey)
if err != nil {
return err
}
remoteID, err := peer.IDFromPublicKey(remotePubkey)
if err != nil {
return err
}
// Validate that ID matches public key
if !remoteID.MatchesPublicKey(remotePubkey) {
calculatedID, _ := peer.IDFromPublicKey(remotePubkey)
return fmt.Errorf("remote peer id does not match public key. id=%s calculated_id=%s",
remoteID, calculatedID)
}
// Add remote ID and key to conn state
ic.remotePubKey = remotePubkey
ic.remote = remoteID
return nil
}
// LocalPeer returns the local peer ID.
......@@ -184,15 +76,14 @@ func (ic *Conn) RemotePeer() peer.ID {
return ic.remote
}
// RemotePublicKey returns whatever public key was given by the remote peer.
// Note that no verification of ownership is done, as this connection is not secure.
// RemotePublicKey returns nil. This connection is not secure
func (ic *Conn) RemotePublicKey() ci.PubKey {
return ic.remotePubKey
return nil
}
// LocalPrivateKey returns the private key for the local peer.
// LocalPrivateKey returns nil. This connection is not secure.
func (ic *Conn) LocalPrivateKey() ci.PrivKey {
return ic.localPrivKey
return nil
}
var _ sec.SecureTransport = (*Transport)(nil)
......
package insecure
import (
"bytes"
"context"
"github.com/libp2p/go-libp2p-core/peer"
"github.com/libp2p/go-libp2p-core/sec"
"io"
"net"
"testing"
ci "github.com/libp2p/go-libp2p-core/crypto"
)
// Run a set of sessions through the session setup and verification.
func TestConnections(t *testing.T) {
clientTpt := newTestTransport(t, ci.RSA, 1024)
serverTpt := newTestTransport(t, ci.Ed25519, 1024)
testConnection(t, clientTpt, serverTpt)
}
func newTestTransport(t *testing.T, typ, bits int) *Transport {
priv, pub, err := ci.GenerateKeyPair(typ, bits)
if err != nil {
t.Fatal(err)
}
id, err := peer.IDFromPublicKey(pub)
if err != nil {
t.Fatal(err)
}
return &Transport{
id: id,
key: priv,
}
}
// Create a new pair of connected TCP sockets.
func newConnPair(t *testing.T) (net.Conn, net.Conn) {
lstnr, err := net.Listen("tcp", "localhost:0")
if err != nil {
t.Fatalf("Failed to listen: %v", err)
return nil, nil
}
var clientErr error
var client net.Conn
addr := lstnr.Addr()
done := make(chan struct{})
go func() {
defer close(done)
client, clientErr = net.Dial(addr.Network(), addr.String())
}()
server, err := lstnr.Accept()
<-done
lstnr.Close()
if err != nil {
t.Fatalf("Failed to accept: %v", err)
}
if clientErr != nil {
t.Fatalf("Failed to connect: %v", clientErr)
}
return client, server
}
// Create a new pair of connected sessions based off of the provided
// session generators.
func connect(t *testing.T, clientTpt, serverTpt *Transport) (sec.SecureConn, sec.SecureConn) {
client, server := newConnPair(t)
// Connect the client and server sessions
done := make(chan struct{})
var clientConn sec.SecureConn
var clientErr error
go func() {
defer close(done)
clientConn, clientErr = clientTpt.SecureOutbound(context.TODO(), client, serverTpt.LocalPeer())
}()
serverConn, serverErr := serverTpt.SecureInbound(context.TODO(), server)
<-done
if serverErr != nil {
t.Fatal(serverErr)
}
if clientErr != nil {
t.Fatal(clientErr)
}
return clientConn, serverConn
}
// Check the peer IDs
func testIDs(t *testing.T, clientTpt, serverTpt *Transport, clientConn, serverConn sec.SecureConn) {
if clientConn.LocalPeer() != clientTpt.LocalPeer() {
t.Fatal("Client Local Peer ID mismatch.")
}
if clientConn.RemotePeer() != serverTpt.LocalPeer() {
t.Fatal("Client Remote Peer ID mismatch.")
}
if clientConn.LocalPeer() != serverConn.RemotePeer() {
t.Fatal("Server Local Peer ID mismatch.")
}
}
// Check the keys
func testKeys(t *testing.T, clientTpt, serverTpt *Transport, clientConn, serverConn sec.SecureConn) {
sk := serverConn.LocalPrivateKey()
pk := sk.GetPublic()
if !sk.Equals(serverTpt.LocalPrivateKey()) {
t.Error("Private key Mismatch.")
}
if !pk.Equals(clientConn.RemotePublicKey()) {
t.Error("Public key mismatch.")
}
}
// Check sending and receiving messages
func testReadWrite(t *testing.T, clientConn, serverConn sec.SecureConn) {
before := []byte("hello world")
_, err := clientConn.Write(before)
if err != nil {
t.Fatal(err)
}
after := make([]byte, len(before))
_, err = io.ReadFull(serverConn, after)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(before, after) {
t.Errorf("Message mismatch. %v != %v", before, after)
}
}
// Setup a new session with a pair of locally connected sockets
func testConnection(t *testing.T, clientTpt, serverTpt *Transport) {
clientConn, serverConn := connect(t, clientTpt, serverTpt)
testIDs(t, clientTpt, serverTpt, clientConn, serverConn)
testKeys(t, clientTpt, serverTpt, clientConn, serverConn)
testReadWrite(t, clientConn, serverConn)
clientConn.Close()
serverConn.Close()
}
PB = $(wildcard *.proto)
GO = $(PB:.proto=.pb.go)
all: $(GO)
%.pb.go: %.proto
protoc --proto_path=$(GOPATH)/src:../../../crypto/pb:. --gogofaster_out=. $<
clean:
rm -f *.pb.go
rm -f *.go
// Code generated by protoc-gen-gogo. DO NOT EDIT.
// source: plaintext.proto
package plaintext_pb
import (
fmt "fmt"
proto "github.com/gogo/protobuf/proto"
pb "github.com/libp2p/go-libp2p-core/crypto/pb"
io "io"
math "math"
)
// Reference imports to suppress errors if they are not otherwise used.
var _ = proto.Marshal
var _ = fmt.Errorf
var _ = math.Inf
// This is a compile-time assertion to ensure that this generated file
// is compatible with the proto package it is being compiled against.
// A compilation error at this line likely means your copy of the
// proto package needs to be updated.
const _ = proto.GoGoProtoPackageIsVersion2 // please upgrade the proto package
type Exchange struct {
Id []byte `protobuf:"bytes,1,opt,name=id" json:"id"`
Pubkey *pb.PublicKey `protobuf:"bytes,2,opt,name=pubkey" json:"pubkey,omitempty"`
}
func (m *Exchange) Reset() { *m = Exchange{} }
func (m *Exchange) String() string { return proto.CompactTextString(m) }
func (*Exchange) ProtoMessage() {}
func (*Exchange) Descriptor() ([]byte, []int) {
return fileDescriptor_aba144f73931b711, []int{0}
}
func (m *Exchange) XXX_Unmarshal(b []byte) error {
return m.Unmarshal(b)
}
func (m *Exchange) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) {
if deterministic {
return xxx_messageInfo_Exchange.Marshal(b, m, deterministic)
} else {
b = b[:cap(b)]
n, err := m.MarshalTo(b)
if err != nil {
return nil, err
}
return b[:n], nil
}
}
func (m *Exchange) XXX_Merge(src proto.Message) {
xxx_messageInfo_Exchange.Merge(m, src)
}
func (m *Exchange) XXX_Size() int {
return m.Size()
}
func (m *Exchange) XXX_DiscardUnknown() {
xxx_messageInfo_Exchange.DiscardUnknown(m)
}
var xxx_messageInfo_Exchange proto.InternalMessageInfo
func (m *Exchange) GetId() []byte {
if m != nil {
return m.Id
}
return nil
}
func (m *Exchange) GetPubkey() *pb.PublicKey {
if m != nil {
return m.Pubkey
}
return nil
}
func init() {
proto.RegisterType((*Exchange)(nil), "plaintext.pb.Exchange")
}
func init() { proto.RegisterFile("plaintext.proto", fileDescriptor_aba144f73931b711) }
var fileDescriptor_aba144f73931b711 = []byte{
// 187 bytes of a gzipped FileDescriptorProto
0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0xe2, 0xe2, 0x2f, 0xc8, 0x49, 0xcc,
0xcc, 0x2b, 0x49, 0xad, 0x28, 0xd1, 0x2b, 0x28, 0xca, 0x2f, 0xc9, 0x17, 0xe2, 0x41, 0x12, 0x48,
0x92, 0x32, 0x4f, 0xcf, 0x2c, 0xc9, 0x28, 0x4d, 0xd2, 0x4b, 0xce, 0xcf, 0xd5, 0xcf, 0xc9, 0x4c,
0x2a, 0x30, 0x2a, 0xd0, 0x4f, 0xcf, 0xd7, 0x85, 0xb0, 0x74, 0x93, 0xf3, 0x8b, 0x52, 0xf5, 0x93,
0x8b, 0x2a, 0x0b, 0x4a, 0xf2, 0xf5, 0x0b, 0x92, 0xa0, 0x2c, 0x88, 0x31, 0x4a, 0x7e, 0x5c, 0x1c,
0xae, 0x15, 0xc9, 0x19, 0x89, 0x79, 0xe9, 0xa9, 0x42, 0x22, 0x5c, 0x4c, 0x99, 0x29, 0x12, 0x8c,
0x0a, 0x8c, 0x1a, 0x3c, 0x4e, 0x2c, 0x27, 0xee, 0xc9, 0x33, 0x04, 0x31, 0x65, 0xa6, 0x08, 0xe9,
0x70, 0xb1, 0x15, 0x94, 0x26, 0x65, 0xa7, 0x56, 0x4a, 0x30, 0x29, 0x30, 0x6a, 0x70, 0x1b, 0x89,
0xe8, 0xc1, 0x0c, 0x48, 0xd2, 0x0b, 0x28, 0x4d, 0xca, 0xc9, 0x4c, 0xf6, 0x4e, 0xad, 0x0c, 0x82,
0xaa, 0x71, 0x92, 0x38, 0xf1, 0x48, 0x8e, 0xf1, 0xc2, 0x23, 0x39, 0xc6, 0x07, 0x8f, 0xe4, 0x18,
0x27, 0x3c, 0x96, 0x63, 0xb8, 0xf0, 0x58, 0x8e, 0xe1, 0xc6, 0x63, 0x39, 0x06, 0x40, 0x00, 0x00,
0x00, 0xff, 0xff, 0x40, 0xde, 0x90, 0x0b, 0xc2, 0x00, 0x00, 0x00,
}
func (m *Exchange) Marshal() (dAtA []byte, err error) {
size := m.Size()
dAtA = make([]byte, size)
n, err := m.MarshalTo(dAtA)
if err != nil {
return nil, err
}
return dAtA[:n], nil
}
func (m *Exchange) MarshalTo(dAtA []byte) (int, error) {
var i int
_ = i
var l int
_ = l
if m.Id != nil {
dAtA[i] = 0xa
i++
i = encodeVarintPlaintext(dAtA, i, uint64(len(m.Id)))
i += copy(dAtA[i:], m.Id)
}
if m.Pubkey != nil {
dAtA[i] = 0x12
i++
i = encodeVarintPlaintext(dAtA, i, uint64(m.Pubkey.Size()))
n1, err := m.Pubkey.MarshalTo(dAtA[i:])
if err != nil {
return 0, err
}
i += n1
}
return i, nil
}
func encodeVarintPlaintext(dAtA []byte, offset int, v uint64) int {
for v >= 1<<7 {
dAtA[offset] = uint8(v&0x7f | 0x80)
v >>= 7
offset++
}
dAtA[offset] = uint8(v)
return offset + 1
}
func (m *Exchange) Size() (n int) {
if m == nil {
return 0
}
var l int
_ = l
if m.Id != nil {
l = len(m.Id)
n += 1 + l + sovPlaintext(uint64(l))
}
if m.Pubkey != nil {
l = m.Pubkey.Size()
n += 1 + l + sovPlaintext(uint64(l))
}
return n
}
func sovPlaintext(x uint64) (n int) {
for {
n++
x >>= 7
if x == 0 {
break
}
}
return n
}
func sozPlaintext(x uint64) (n int) {
return sovPlaintext(uint64((x << 1) ^ uint64((int64(x) >> 63))))
}
func (m *Exchange) Unmarshal(dAtA []byte) error {
l := len(dAtA)
iNdEx := 0
for iNdEx < l {
preIndex := iNdEx
var wire uint64
for shift := uint(0); ; shift += 7 {
if shift >= 64 {
return ErrIntOverflowPlaintext
}
if iNdEx >= l {
return io.ErrUnexpectedEOF
}
b := dAtA[iNdEx]
iNdEx++
wire |= uint64(b&0x7F) << shift
if b < 0x80 {
break
}
}
fieldNum := int32(wire >> 3)
wireType := int(wire & 0x7)
if wireType == 4 {
return fmt.Errorf("proto: Exchange: wiretype end group for non-group")
}
if fieldNum <= 0 {
return fmt.Errorf("proto: Exchange: illegal tag %d (wire type %d)", fieldNum, wire)
}
switch fieldNum {
case 1:
if wireType != 2 {
return fmt.Errorf("proto: wrong wireType = %d for field Id", wireType)
}
var byteLen int
for shift := uint(0); ; shift += 7 {
if shift >= 64 {
return ErrIntOverflowPlaintext
}
if iNdEx >= l {
return io.ErrUnexpectedEOF
}
b := dAtA[iNdEx]
iNdEx++
byteLen |= int(b&0x7F) << shift
if b < 0x80 {
break
}
}
if byteLen < 0 {
return ErrInvalidLengthPlaintext
}
postIndex := iNdEx + byteLen
if postIndex < 0 {
return ErrInvalidLengthPlaintext
}
if postIndex > l {
return io.ErrUnexpectedEOF
}
m.Id = append(m.Id[:0], dAtA[iNdEx:postIndex]...)
if m.Id == nil {
m.Id = []byte{}
}
iNdEx = postIndex
case 2:
if wireType != 2 {
return fmt.Errorf("proto: wrong wireType = %d for field Pubkey", wireType)
}
var msglen int
for shift := uint(0); ; shift += 7 {
if shift >= 64 {
return ErrIntOverflowPlaintext
}
if iNdEx >= l {
return io.ErrUnexpectedEOF
}
b := dAtA[iNdEx]
iNdEx++
msglen |= int(b&0x7F) << shift
if b < 0x80 {
break
}
}
if msglen < 0 {
return ErrInvalidLengthPlaintext
}
postIndex := iNdEx + msglen
if postIndex < 0 {
return ErrInvalidLengthPlaintext
}
if postIndex > l {
return io.ErrUnexpectedEOF
}
if m.Pubkey == nil {
m.Pubkey = &pb.PublicKey{}
}
if err := m.Pubkey.Unmarshal(dAtA[iNdEx:postIndex]); err != nil {
return err
}
iNdEx = postIndex
default:
iNdEx = preIndex
skippy, err := skipPlaintext(dAtA[iNdEx:])
if err != nil {
return err
}
if skippy < 0 {
return ErrInvalidLengthPlaintext
}
if (iNdEx + skippy) < 0 {
return ErrInvalidLengthPlaintext
}
if (iNdEx + skippy) > l {
return io.ErrUnexpectedEOF
}
iNdEx += skippy
}
}
if iNdEx > l {
return io.ErrUnexpectedEOF
}
return nil
}
func skipPlaintext(dAtA []byte) (n int, err error) {
l := len(dAtA)
iNdEx := 0
for iNdEx < l {
var wire uint64
for shift := uint(0); ; shift += 7 {
if shift >= 64 {
return 0, ErrIntOverflowPlaintext
}
if iNdEx >= l {
return 0, io.ErrUnexpectedEOF
}
b := dAtA[iNdEx]
iNdEx++
wire |= (uint64(b) & 0x7F) << shift
if b < 0x80 {
break
}
}
wireType := int(wire & 0x7)
switch wireType {
case 0:
for shift := uint(0); ; shift += 7 {
if shift >= 64 {
return 0, ErrIntOverflowPlaintext
}
if iNdEx >= l {
return 0, io.ErrUnexpectedEOF
}
iNdEx++
if dAtA[iNdEx-1] < 0x80 {
break
}
}
return iNdEx, nil
case 1:
iNdEx += 8
return iNdEx, nil
case 2:
var length int
for shift := uint(0); ; shift += 7 {
if shift >= 64 {
return 0, ErrIntOverflowPlaintext
}
if iNdEx >= l {
return 0, io.ErrUnexpectedEOF
}
b := dAtA[iNdEx]
iNdEx++
length |= (int(b) & 0x7F) << shift
if b < 0x80 {
break
}
}
if length < 0 {
return 0, ErrInvalidLengthPlaintext
}
iNdEx += length
if iNdEx < 0 {
return 0, ErrInvalidLengthPlaintext
}
return iNdEx, nil
case 3:
for {
var innerWire uint64
var start int = iNdEx
for shift := uint(0); ; shift += 7 {
if shift >= 64 {
return 0, ErrIntOverflowPlaintext
}
if iNdEx >= l {
return 0, io.ErrUnexpectedEOF
}
b := dAtA[iNdEx]
iNdEx++
innerWire |= (uint64(b) & 0x7F) << shift
if b < 0x80 {
break
}
}
innerWireType := int(innerWire & 0x7)
if innerWireType == 4 {
break
}
next, err := skipPlaintext(dAtA[start:])
if err != nil {
return 0, err
}
iNdEx = start + next
if iNdEx < 0 {
return 0, ErrInvalidLengthPlaintext
}
}
return iNdEx, nil
case 4:
return iNdEx, nil
case 5:
iNdEx += 4
return iNdEx, nil
default:
return 0, fmt.Errorf("proto: illegal wireType %d", wireType)
}
}
panic("unreachable")
}
var (
ErrInvalidLengthPlaintext = fmt.Errorf("proto: negative length found during unmarshaling")
ErrIntOverflowPlaintext = fmt.Errorf("proto: integer overflow")
)
syntax = "proto2";
package plaintext.pb;
import "github.com/libp2p/go-libp2p-core/crypto/pb/crypto.proto";
message Exchange {
optional bytes id = 1;
optional crypto.pb.PublicKey pubkey = 2;
}
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