Initial commit

This commit is contained in:
Jazz Turner-Baggs 2025-07-05 14:54:19 -07:00
parent 9a01b8c6fa
commit db47ffd5a4
18 changed files with 563 additions and 3 deletions

View File

@ -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"
requires "web3 >= 0.7.0"

View File

@ -0,0 +1,9 @@
syntax = "proto3";
package umbra.common_frames;
message ContentFrame {
uint32 domain = 1;
uint32 tag = 2;
bytes bytes = 3;
}

View File

@ -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;
// ...
}
}

View File

@ -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;
// ....
}
}

24
protos/encryption.proto Normal file
View File

@ -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;
}

16
protos/envelope.proto Normal file
View File

@ -0,0 +1,16 @@
syntax = "proto3";
package umbra.envelope;
///////////////////////////////////////////////////////////////////////////////
// Payload Framing Messages
///////////////////////////////////////////////////////////////////////////////
message UmbraEnvelopeV1 {
string conversation_hint = 1;
uint64 salt = 2;
bytes payload = 5;
}

12
protos/inbox.proto Normal file
View File

@ -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;
}
}

7
protos/invite.proto Normal file
View File

@ -0,0 +1,7 @@
syntax = "proto3";
package umbra.invite;
message InvitePrivateV1 {
repeated string participants = 1;
}

23
protos/reliability.proto Normal file
View File

@ -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;
}

175
src/client.nim Normal file
View File

@ -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

View File

@ -0,0 +1,4 @@
type
PrivateV1* = object
# Placeholder for PrivateV1 conversation type
name*: string

33
src/crypto.nim Normal file
View File

@ -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()

6
src/dev.nim Normal file
View File

@ -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

30
src/identity.nim Normal file
View File

@ -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

65
src/inbox.nim Normal file
View File

@ -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)

View File

@ -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...")

57
src/proto_types.nim Normal file
View File

@ -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

View File

@ -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, "")