diff --git a/nim_chat_poc.nimble b/nim_chat_poc.nimble index 6f40892..a087563 100644 --- a/nim_chat_poc.nimble +++ b/nim_chat_poc.nimble @@ -26,4 +26,4 @@ requires "nimchacha20poly1305" # TODO: remove requires "confutils >= 0.1.0" requires "eth >= 0.8.0" requires "regex >= 0.26.3" -requires "web3 >= 0.7.0" \ No newline at end of file +requires "web3 >= 0.7.0" diff --git a/protos/common_frames.proto b/protos/common_frames.proto new file mode 100644 index 0000000..453c00f --- /dev/null +++ b/protos/common_frames.proto @@ -0,0 +1,9 @@ +syntax = "proto3"; + +package umbra.common_frames; + +message ContentFrame { + uint32 domain = 1; + uint32 tag = 2; + bytes bytes = 3; +} diff --git a/protos/conversations/group_v1.proto b/protos/conversations/group_v1.proto new file mode 100644 index 0000000..34d7ca2 --- /dev/null +++ b/protos/conversations/group_v1.proto @@ -0,0 +1,28 @@ +syntax = "proto3"; + +package umbra.convos.group_v1; + +import "base.proto"; +import "common_frames.proto"; + + + +message ConversationInvite_GroupV1 { + repeated string participants = 1; +} + + + +message GroupV1Frame { + // SDS like information: Message ID and channel_id extracted for utility + string message_id = 2; + string channel_id = 3; // Channel_id is associated with a set of participants + // This conflicts with conversation based encryption, + // need to ensure the derived sender is a valid participant + base.ReliabilityInfo reliability_info = 10; + + oneof frame_type { + common_frames.ContentFrame content = 100; + // ... + } +} diff --git a/protos/conversations/private_v1.proto b/protos/conversations/private_v1.proto new file mode 100644 index 0000000..43a6a73 --- /dev/null +++ b/protos/conversations/private_v1.proto @@ -0,0 +1,22 @@ +syntax = "proto3"; + +package umbra.convos.private_v1; + +import "common_frames.proto"; + + + + +message Placeholder { + uint32 counter = 1; +} + +message PrivateV1Frame { + string conversation_id = 1; + + oneof frame_type { + common_frames.ContentFrame content = 10; + Placeholder placeholder = 11; + // .... + } +} diff --git a/protos/encryption.proto b/protos/encryption.proto new file mode 100644 index 0000000..a579382 --- /dev/null +++ b/protos/encryption.proto @@ -0,0 +1,24 @@ +syntax = "proto3"; + +package umbra.encryption; + + +// TODO: This also encompasses plaintexts, is there a better name? +// Alternatives: ??? +message EncryptedPayload { + + oneof encryption { + encryption.Plaintext plaintext = 1; + encryption.Ecies ecies = 2; + } +} + +message Plaintext { + bytes payload=1; +} + +message Ecies { + bytes encrypted_bytes=1; + bytes ephemeral_pubkey = 2; + bytes tag = 3; +} diff --git a/protos/envelope.proto b/protos/envelope.proto new file mode 100644 index 0000000..0b0d2ea --- /dev/null +++ b/protos/envelope.proto @@ -0,0 +1,16 @@ +syntax = "proto3"; + +package umbra.envelope; + + +/////////////////////////////////////////////////////////////////////////////// +// Payload Framing Messages +/////////////////////////////////////////////////////////////////////////////// + +message UmbraEnvelopeV1 { + + string conversation_hint = 1; + uint64 salt = 2; + + bytes payload = 5; +} diff --git a/protos/inbox.proto b/protos/inbox.proto new file mode 100644 index 0000000..ad4fed4 --- /dev/null +++ b/protos/inbox.proto @@ -0,0 +1,12 @@ +syntax = "proto3"; + +package umbra.inbox; + +import "invite.proto"; + +message InboxV1Frame { + string recipient = 1; + oneof frame_type { + invite.InvitePrivateV1 invite_private_v1 = 10; + } +} diff --git a/protos/invite.proto b/protos/invite.proto new file mode 100644 index 0000000..c94b442 --- /dev/null +++ b/protos/invite.proto @@ -0,0 +1,7 @@ +syntax = "proto3"; + +package umbra.invite; + +message InvitePrivateV1 { + repeated string participants = 1; +} diff --git a/protos/reliability.proto b/protos/reliability.proto new file mode 100644 index 0000000..3c433c3 --- /dev/null +++ b/protos/reliability.proto @@ -0,0 +1,23 @@ +syntax = "proto3"; + +package umbra.reliability; + +/////////////////////////////////////////////////////////////////////////////// +// SDS Payloads +/////////////////////////////////////////////////////////////////////////////// + +message HistoryEntry { + string message_id = 1; // Unique identifier of the SDS message, as defined in `Message` + bytes retrieval_hint = 2; // Optional information to help remote parties retrieve this SDS + // message; For example, A Waku deterministic message hash or routing payload hash + } + + message ReliablePayload { + string message_id = 2; + string channel_id = 3; + int32 lamport_timestamp = 10; + repeated HistoryEntry causal_history = 11; + bytes bloom_filter = 12; + // Optional field causes errors in nim protobuf generation. Removing for now as optional is implied anways. + bytes content = 20; + } diff --git a/src/client.nim b/src/client.nim new file mode 100644 index 0000000..0b5f246 --- /dev/null +++ b/src/client.nim @@ -0,0 +1,175 @@ +import tables +import identity +import crypto +import proto_types +import std/times +import utils +import dev +import inbox + +import conversations/private_v1 + +import secp256k1 +import chronicles + +type KeyEntry* = object + keytype: string + keypair: SkKeyPair + timestamp: int64 + + +type SupportedConvoTypes* = Inbox | PrivateV1 + +type + ConvoType* = enum + InboxV1Type, PrivateV1Type + + ConvoWrapper* = object + case convo_type*: ConvoType + of InboxV1Type: + inboxV1*: Inbox + of PrivateV1Type: + privateV1*: PrivateV1 + + +type + Client* = object + ident: Identity + key_store: Table[string, KeyEntry] # Keyed by HexEncoded Public Key + conversations: Table[string, ConvoWrapper] # Keyed by conversation ID + + +proc process_invite*(self: Client, invite: InvitePrivateV1) + + +################################################# +# Constructors +################################################# + +proc initClient*(name: string): Client = + + + var c = Client(ident: createIdentity(name), + key_store: initTable[string, KeyEntry](), + conversations: initTable[string, ConvoWrapper]()) + + let default_inbox = initInbox(c.ident.getAddr(), proc( + x: InvitePrivateV1) = c.process_invite(x)) + + c.conversations[conversation_id_for(c.ident.getPubkey( + ))] = ConvoWrapper(convo_type: InboxV1Type, inboxV1: default_inbox) + + result = c + + +################################################# +# Parameter Access +################################################# + +proc getClientAddr*(self: Client): string = + result = self.ident.getAddr() + +proc default_inbox_conversation_id*(self: Client): string = + ## Returns the default inbox address for the client. + result = conversation_id_for(self.ident.getPubkey()) + +################################################# +# Methods +################################################# + +# proc addConversation*(c: var Client, convo: Conversation) = +# if not c.conversations.hasKey(convo.getConvoId()): +# c.conversations[convo.getConvoId()] = convo +# else: +# echo "Conversation with ID ", convo.getConvoId(), " already exists." + + +proc createIntroBundle*(self: var Client): IntroBundle = + ## Generates an IntroBundle for the client, which includes + ## the required information to send a message. + + # Create Ephemeral keypair, save it in the key store + let ephemeral_keypair = generate_keypair() + self.key_store[ephemeral_keypair.pubkey.toHexCompressed()] = KeyEntry( + keytype: "ephemeral", + keypair: ephemeral_keypair, + timestamp: getTime().toUnix(), + ) + + result = IntroBundle( + ident: @(self.ident.getPubkey().toRawCompressed()), + ephemeral: @(ephemeral_keypair.pubkey.toRawCompressed()), + ) + +proc createPrivateConvo*(self: Client, intro_bundle: IntroBundle): TransportMessage = + ## Creates a private conversation with the given Invitebundle. + + + + let res_pubkey = SkPublicKey.fromRaw(intro_bundle.ident) + if res_pubkey.isErr: + raise newException(ValueError, "Invalid public key in intro bundle.") + let dest_pubkey = res_pubkey.get() + let convo_id = "/convo/inbox/" & dest_pubkey.getAddr() + + let dst_convo_topic = topic_inbox(dest_pubkey.get_addr()) + + let invite = InvitePrivateV1( + participants: @[self.ident.getAddr(), dest_pubkey.get_addr()], + ) + let env = wrap_env(encrypt(InboxV1Frame(invite_private_v1: invite, + recipient: "")), convo_id) + + # Create a new conversation + # let convo_id = self.ident.getAddr() & "-" & raya_bundle.ident.toHexCompressed() + # let new_convo = Conversation(id: convo_id, participants: @[self.ident, + # Identity(name: "Raya", keypair: SkKeyPair.fromRawCompressed( + # raya_bundle.ident))]) + + # Add the conversation to the client's store + # self.addConversation(new_convo) + + # Return the invite (or any other relevant data) + # return new_convo + + return sendTo(dst_convo_topic, encode(env)) + +proc get_conversation(self: Client, + conversation_hint: string): Result[Option[ConvoWrapper], string] = + + # TODO: Implementing Hinting + if not self.conversations.hasKey(conversation_hint): + ok(none(ConvoWrapper)) + else: + ok(some(self.conversations[conversation_hint])) + + +proc recv*(self: var Client, transport_message: TransportMessage): seq[ + TransportMessage] = + ## Reveives a incomming payload, decodes it, and processes it. + let res_env = decode(transport_message.payload, UmbraEnvelopeV1) + if res_env.isErr: + raise newException(ValueError, "Failed to decode UmbraEnvelopeV1: " & res_env.error) + let env = res_env.get() + + let res_convo = self.get_conversation(env.conversation_hint) + if res_convo.isErr: + raise newException(ValueError, "Failed to get conversation: " & + res_convo.error) + + let convo = res_convo.get() + if not convo.isSome: + debug "No conversation found", hint = env.conversation_hint + return + + let inbox = convo.get().inboxV1 + + let res = inbox.handle_incomming_frame(transport_message.topic, env.payload) + if res.isErr: + warn "Failed to handle incoming frame: ", error = res.error + return @[] + + + +proc process_invite*(self: Client, invite: InvitePrivateV1) = + debug "Callback Invoked", invite = invite diff --git a/src/conversations/private_v1.nim b/src/conversations/private_v1.nim new file mode 100644 index 0000000..93657c3 --- /dev/null +++ b/src/conversations/private_v1.nim @@ -0,0 +1,4 @@ +type + PrivateV1* = object + # Placeholder for PrivateV1 conversation type + name*: string diff --git a/src/crypto.nim b/src/crypto.nim new file mode 100644 index 0000000..da043b7 --- /dev/null +++ b/src/crypto.nim @@ -0,0 +1,33 @@ +import proto_types + +import secp256k1 +import std/[sysrand] +export secp256k1 + + +type KeyPair* = SkKeyPair + + + +proc encrypt_plain*[T: EncryptableTypes](frame: T): EncryptedPayload = + return EncryptedPayload( + plaintext: Plaintext(payload: encode(frame)), + ) + +proc decrypt_plain*[T: EncryptableTypes](ciphertext: Plaintext, t: typedesc[ + T]): Result[T, string] = + + let obj = decode(ciphertext.payload, T) + if obj.isErr: + return err("Protobuf decode failed: " & obj.error) + + result = ok(obj.get()) + +proc generate_keypair*(): KeyPair = + var rng: Rng = urandom + let res = SkKeyPair.random(rng) + if res.isErr: + raise newException(ValueError, "Failed to generate keypair: ") + + result = res.get() + diff --git a/src/dev.nim b/src/dev.nim new file mode 100644 index 0000000..a9b767e --- /dev/null +++ b/src/dev.nim @@ -0,0 +1,6 @@ +## Utilties for development and debugging + +proc dir*[T](obj: T) = + echo "Object of type: ", T + for name, value in fieldPairs(obj): + echo " ", name, ": ", value diff --git a/src/identity.nim b/src/identity.nim new file mode 100644 index 0000000..c30d8b1 --- /dev/null +++ b/src/identity.nim @@ -0,0 +1,30 @@ + +import crypto +import utils + + +type + Identity* = object + name: string + keypair: KeyPair + + +################################################# +# Constructors +################################################# + +proc createIdentity*(name: string): Identity = + let keypair = generate_keypair() + result = Identity(name: name, keypair: keypair) + + +################################################# +# Parameter Access +################################################# + +proc getAddr*(self: Identity): string = + result = get_addr(self.keypair.pubkey) + + +proc getPubkey*(self: Identity): SkPublicKey = + result = self.keypair.pubkey diff --git a/src/inbox.nim b/src/inbox.nim new file mode 100644 index 0000000..9479e25 --- /dev/null +++ b/src/inbox.nim @@ -0,0 +1,65 @@ +import crypto +import proto_types +import utils +import dev + +import chronicles + + +type InviteCallback* = proc(invite: InvitePrivateV1): void + + +type + Inbox* = object + inbox_addr: string + invite_callback: InviteCallback + +proc initInbox*(inbox_addr: string, invite_callback: InviteCallback): Inbox = + ## Initializes an Inbox object with the given address and invite callback. + return Inbox(inbox_addr: inbox_addr, invite_callback: invite_callback) + + +proc encrypt*(frame: InboxV1Frame): EncryptedPayload = + return encrypt_plain(frame) + +proc wrap_env*(payload: EncryptedPayload, convo_id: string): UmbraEnvelopeV1 = + let bytes = encode(payload) + let salt = generateSalt() + + return UmbraEnvelopeV1( + payload: bytes, + salt: salt, + conversation_hint: convo_id, + ) + +proc conversation_id_for*(pubkey: SkPublicKey): string = + ## Generates a conversation ID based on the public key. + return "/convo/inbox/" & pubkey.get_addr() + +# TODO derive this from instance of Inbox +proc topic_inbox*(client_addr: string): string = + return "/inbox/v1/" & client_addr + + +proc handle_incomming_frame*(self: Inbox, topic: string, bytes: seq[ + byte]): Result[int, string] = + # TODO: Can this fail? + let res = decode(bytes, EncryptedPayload) + if res.isErr: + return err("Failed to decode payload: " & res.error) + + let encbytes = res.get() + + # NOTE! in nim_protobuf_serializaiton OneOf fields are not exclusive, and all fields are default initialized. + if encbytes.plaintext == Plaintext(): + return err("Incorrect Encryption Type") + + let res_frame = decrypt_plain(encbytes.plaintext, InboxV1Frame) + if res_frame.isErr: + return err("Failed to decrypt frame: " & res_frame.error) + let frame = res_frame.get() + + self.invite_callback(frame.invite_private_v1) + ok(0) + + diff --git a/src/nim_chat_poc.nim b/src/nim_chat_poc.nim index 56ba516..5b315d3 100644 --- a/src/nim_chat_poc.nim +++ b/src/nim_chat_poc.nim @@ -1,3 +1,4 @@ + import chronicles, chronos, @@ -28,3 +29,39 @@ proc main(): Future[void] {.async.} = when isMainModule: waitFor main() + +# import client +# import chronicles +# import proto_types + +# proc log(transport_message: TransportMessage) = +# ## Log the transport message +# info "Transport Message:", topic = transport_message.topic, +# payload = transport_message.payload + +# proc demo() = + +# # Initalize Clients +# var saro = initClient("Saro") +# var raya = initClient("Raya") + +# # # Exchange Contact Info +# let raya_bundle = raya.createIntroBundle() + +# # Create Conversation +# let invite = saro.createPrivateConvo(raya_bundle) +# invite.log() +# let msgs = raya.recv(invite) + +# # raya.convos()[0].sendText("Hello Saro, this is Raya!") + + +# when isMainModule: +# echo("Starting ChatPOC...") + +# try: +# demo() +# except Exception as e: +# error "Crashed ", error = e.msg + +# echo("Finished...") diff --git a/src/proto_types.nim b/src/proto_types.nim new file mode 100644 index 0000000..bb32290 --- /dev/null +++ b/src/proto_types.nim @@ -0,0 +1,57 @@ +# Can this be an external package? It would be preferable to have these types +# easy to import and use. + +import protobuf_serialization # This import is needed or th macro will not work +import protobuf_serialization/proto_parser +import results + +export protobuf_serialization + +import_proto3 "../protos/inbox.proto" +# import_proto3 "../protos/invite.proto" // Import3 follows protobuf includes so this will result in a redefinition error +import_proto3 "../protos/encryption.proto" +import_proto3 "../protos/envelope.proto" + +type EncryptableTypes = InboxV1Frame | EncryptedPayload + +export EncryptedPayload +export InboxV1Frame + +export EncryptableTypes + + + + +proc encode*(frame: object): seq[byte] = + ## Encodes the frame into a byte sequence using Protobuf serialization. + result = Protobuf.encode(frame) + + +proc decode*[T: object] (bytes: seq[byte], proto: typedesc[ + T]): Result[T, string] = + ## Encodes the frame into a byte sequence using Protobuf serialization. + + try: + result = ok(Protobuf.decode(bytes, proto)) + except ProtobufError as e: + result = err("Failed to decode payload: " & e.msg) + +type + IntroBundle {.proto3.} = object + ident* {.fieldNumber: 1.}: seq[byte] + ephemeral* {.fieldNumber: 2.}: seq[byte] + + +export IntroBundle + + +type + TransportMessage {.proto3.} = object + topic* {.fieldNumber: 1.}: string + payload* {.fieldNumber: 2.}: seq[byte] + + +proc sendTo*(topic: string, payload: seq[byte]): TransportMessage = + result = TransportMessage(topic: topic, payload: payload) + +export TransportMessage diff --git a/src/utils.nim b/src/utils.nim index 3d8ae4b..1456452 100644 --- a/src/utils.nim +++ b/src/utils.nim @@ -1,6 +1,18 @@ -import std/times import waku/waku_core - +import std/[random, times] +import crypto +import blake2 proc getTimestamp*(): Timestamp = result = waku_core.getNanosecondTime(getTime().toUnix()) + +proc generateSalt*(): uint64 = + randomize() + result = 0 + for i in 0 ..< 8: + result = result or (uint64(rand(255)) shl (i * 8)) + +proc get_addr*(pubkey: SkPublicKey): string = + # TODO: Needs Spec + result = getBlake2b(pubkey.toHexCompressed(), 4, "") +