Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions examples/pingpong.nim
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,10 @@ proc main() {.async.} =
let sIdent = Identity(name: "saro", privateKey: sKey)

# Create Clients
info "create saro client"
var saro = newClient(cfg_saro, sIdent)

info "create raya client"
var raya = newClient(cfg_raya, Identity(name: "raya", privateKey: rKey))

var ri = 0
Expand Down
22 changes: 20 additions & 2 deletions src/chat_sdk/conversations/private_v1.nim
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,16 @@ proc calcMsgId(self: PrivateV1, msgBytes: seq[byte]): string =

proc encrypt*(convo: PrivateV1, plaintext: var seq[byte]): EncryptedPayload =

info "encrypt metadata"
info "Doubleratchet DH Self: ", dhSelf = convo.doubleratchet.dhSelf
info "dh self public:" , dhSelfPub = convo.doubleratchet.dhSelf.public
info "dhRemote: ", dhRemote = convo.doubleratchet.dhRemote

let (header, ciphertext) = convo.doubleratchet.encrypt(plaintext) #TODO: Associated Data

info "encrypt done"
info "header dh public: ", dhPub = header.dhPublic

result = EncryptedPayload(doubleratchet: proto_types.DoubleRatchet(
dh: toSeq(header.dhPublic),
msgNum: header.msgNumber,
Expand All @@ -93,6 +101,16 @@ proc decrypt*(convo: PrivateV1, enc: EncryptedPayload): Result[seq[byte], ChatEr
prevChainLen: dr.prevChainLen
)
copyMem(addr header.dhPublic[0], unsafeAddr dr.dh[0], dr.dh.len) # TODO: Avoid this copy

info "decrypt metadata"
info "header dh public: ", dhPub = header.dhPublic
info "Doubleratchet DH Remote: ", dhRemote = convo.doubleratchet.dhRemote
info "dh self:", dhSelf = convo.doubleratchet.dhSelf
info "dh self public:" , dhSelfPub = convo.doubleratchet.dhSelf.public

if convo.doubleratchet.dhSelf.public == header.dhPublic:
info "outgoing message, no need to decrypt"
return err(ChatError(code: errDecryptOutgoing, context: "Attempted to decrypt outgoing message"))

convo.doubleratchet.decrypt(header, dr.ciphertext, @[]).mapErr(proc(e: NaxolotlError): ChatError = ChatError(code: errWrapped, context: repr(e) ))

Expand Down Expand Up @@ -127,7 +145,7 @@ proc wireCallbacks(convo: PrivateV1, deliveryAckCb: proc(


proc initPrivateV1*(owner: Identity, participant: PublicKey, seedKey: array[32, byte],
discriminator: string = "default", isSender: bool, deliveryAckCb: proc(
discriminator: string = "default", inviter: bool, deliveryAckCb: proc(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think moving away from the "isSender" convention makes sense here.

[Sand] Perhaps "Initiator" would be a better fit as it keeps the language consistent.

Copy link
Contributor Author

@kaichaosun kaichaosun Nov 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When reading the proto definition, I somehow need to think twice to reason the term participant (initiator seems fine though). The term inviter/invitee actually looks a bit clear and straightforward to me.

message InvitePrivateV1 {
    bytes initiator = 1;
    bytes initiator_ephemeral = 2;
    bytes participant = 3;
    int32 participant_ephemeral_id= 4;
    string discriminator = 5;
}

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clear unambiguous terms are important. Feel free to suggest new ones here: logos-messaging/specs#80

conversation: Conversation,
msgId: string): Future[void] {.async.} = nil):
PrivateV1 =
Expand All @@ -143,7 +161,7 @@ proc initPrivateV1*(owner: Identity, participant: PublicKey, seedKey: array[32,
topic: derive_topic(participants, discriminator),
participant: participant,
discriminator: discriminator,
doubleratchet: initDoubleratchet(seedKey, owner.privateKey.bytes, participant.bytes, isSender)
doubleratchet: initDoubleratchet(seedKey, owner.privateKey.bytes, participant.bytes, inviter)
)

result.wireCallbacks(deliveryAckCb)
Expand Down
4 changes: 2 additions & 2 deletions src/chat_sdk/crypto.nim
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,13 @@ export PublicKey, PrivateKey, bytes, createRandomKey, loadPrivateKeyFromBytes, l

proc encrypt_plain*[T: EncryptableTypes](frame: T): EncryptedPayload =
return EncryptedPayload(
plaintext: Plaintext(payload: encode(frame)),
plaintext: Plaintext(payload: proto_types.encode(frame)),
)

proc decrypt_plain*[T: EncryptableTypes](ciphertext: Plaintext, t: typedesc[
T]): Result[T, string] =

let obj = decode(ciphertext.payload, T)
let obj = proto_types.decode(ciphertext.payload, T)
if obj.isErr:
return err("Protobuf decode failed: " & obj.error)
result = ok(obj.get())
Expand Down
1 change: 1 addition & 0 deletions src/chat_sdk/errors.nim
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ type
ErrorCode* = enum
errTypeError
errWrapped
errDecryptOutgoing


proc `$`*(x: ChatError): string =
Expand Down
10 changes: 8 additions & 2 deletions src/chat_sdk/inbox.nim
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import
types,
utils

import ../content_types

logScope:
topics = "chat inbox"

Expand Down Expand Up @@ -42,7 +44,7 @@ proc decrypt*(inbox: Inbox, encbytes: EncryptedPayload): Result[InboxV1Frame, st
result = res_frame

proc wrap_env*(payload: EncryptedPayload, convo_id: string): WapEnvelopeV1 =
let bytes = encode(payload)
let bytes = proto_types.encode(payload)
let salt = generateSalt()

return WapEnvelopeV1(
Expand Down Expand Up @@ -87,12 +89,16 @@ proc createPrivateV1FromInvite*[T: ConversationStore](client: T,
topic = convo.getConvoId()
client.addConversation(convo)

# TODO send a control frame instead
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure why any frame needs to be sent here.

A Recipient ought to be able to initialize an inbound session and receive messages without revealing activity to the sender

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if I miss something, here is my thought:

After both run initDoubleratchet
initiator(Alice), dh is id key, dh remote is bob's id pub key, send key is empty
recipient (Bob), dh is a new generated key,, dh remote is alice's id pub key, send key and new root key derived from DH.

Without sending a control message, if alice sends message to bob, alice will use message key derived from empty send key. This seems not what we want.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Without sending a control message, if alice sends message to bob, alice will use message key derived from empty send key

You are correct in that the DH step requires a key from the remote party before it can occur.

Alice can use bobs static installation key as the initial DHr. This should ultimately be replaced with a signed pre-key equivalent once the work on identity is completed

Requiring a response from Bob to complete initialization, would remove the non-interactive protocol property

discard convo.sendMessage(client.ds, initTextFrame("Hello").toContentFrame())
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a tracking ticket to make sendMessage content Agnostic.
#21



proc handleFrame*[T: ConversationStore](convo: Inbox, client: T, bytes: seq[
byte]) =
## Dispatcher for Incoming `InboxV1Frames`.
## Calls further processing depending on the kind of frame.

let enc = decode(bytes, EncryptedPayload).valueOr:
let enc = proto_types.decode(bytes, EncryptedPayload).valueOr:
raise newException(ValueError, "Failed to decode payload")

let frame = convo.decrypt(enc).valueOr:
Expand Down
1 change: 0 additions & 1 deletion src/content_types/all.nim
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,6 @@ proc toContentFrame*(frame: TextFrame): ContentFrame =
proc initTextFrame*(text: string): TextFrame =
result = TextFrame(encoding: ord(Utf8), text: text)


proc `$`*(frame: TextFrame): string =

result = fmt"TextFrame(encoding:{TextEncoding(frame.encoding)} text:{frame.text})"
Expand Down
27 changes: 21 additions & 6 deletions src/naxolotl/naxolotl.nim
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ const maxSkip = 10


type Doubleratchet* = object
dhSelf: PrivateKey
dhRemote: PublicKey
dhSelf*: PrivateKey
dhRemote*: PublicKey

rootKey: RootKey
chainKeySend: ChainKey
Expand Down Expand Up @@ -73,15 +73,20 @@ func kdfChain(self: Doubleratchet, chainKey: ChainKey): (MessageKey, ChainKey) =

return(msgKey, chainKey)

func dhRatchetSend(self: var Doubleratchet) =
proc dhRatchetSend(self: var Doubleratchet) =
# Perform DH Ratchet step when receiving a new peer key.
info "dhRatchetSend DH Self: ", dhSelf = self.dhSelf
self.dhSelf = generateKeypair().get()[0]
info "dhRatchetSend new DH Self: ", dhSelf = self.dhSelf
let dhOutput : DhDerivedKey = dhExchange(self.dhSelf, self.dhRemote).get()
let (newRootKey, newChainKeySend) = kdfRoot(self, self.rootKey, dhOutput)
self.rootKey = newRootKey
self.chainKeySend = newChainKeySend
self.msgCountSend = 0

proc dhRatchetRecv(self: var Doubleratchet, remotePublickey: PublicKey ) =
info "dh ratchet happens"
info "dhRatchetRecv DH Remote: ", dhRemote = remotePublickey
self.prevChainLen = self.msgCountSend
self.msgCountSend = 0
self.msgCountRecv = 0
Expand All @@ -96,7 +101,7 @@ proc dhRatchetRecv(self: var Doubleratchet, remotePublickey: PublicKey ) =
self.dhSelf = generateKeypair().get()[0]

let dhOutputPost = self.dhSelf.dhExchange(self.dhRemote).get()
(self.rootKey, self.chainKeyRecv) = kdfRoot(self, self.rootKey, dhOutputPost)
(self.rootKey, self.chainKeySend) = kdfRoot(self, self.rootKey, dhOutputPost)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice catch.

I'd suggest getting rid of the direct kdf call all togther and use dhRatchetSend which also initializes msgCountSend

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dhRatchetRecv calls dhRatchetSend is pretty confusing, we may need to rename both terms and see how to reuse some code.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In a correct implementation, the sendingChainKey is not generated until it is needed to minimize key exposure.

dhRatchetRecv: Performs the first part of the ratchet step when receiving DHr
dhRatchetSend: Performs the second part once the client wants to send a message.

One path to clean this up, is to remove keyGeneration and subsequent KeyExchange from this function all together.



proc skipMessageKeys(self: var Doubleratchet, until: MsgCount): Result[(), string] =
Expand Down Expand Up @@ -138,9 +143,15 @@ proc encrypt(self: var Doubleratchet, plaintext: var seq[byte], associatedData:


proc decrypt*(self: var Doubleratchet, header: DrHeader, ciphertext: CipherText, associatedData: openArray[byte] ) : Result[seq[byte], NaxolotlError] =
info "double ratchet decrypt", header = $header
info "dhRemote: ", dhRemote = self.dhRemote
info "dhSelf: ", dhSelf = self.dhSelf
info "dhSelf public: ", dhSelf = self.dhSelf.public

let peerPublic = header.dhPublic

info "peerPublic: ", peerPublic = peerPublic

var msgKey : MessageKey

# Check Skipped Keys
Expand Down Expand Up @@ -176,8 +187,12 @@ proc encrypt*(self: var Doubleratchet, plaintext: var seq[byte]) : (DrHeader, Ci
encrypt(self, plaintext,@[])


func initDoubleratchet*(sharedSecret: array[32, byte], dhSelf: PrivateKey, dhRemote: PublicKey, isSending: bool = true): Doubleratchet =
proc initDoubleratchet*(sharedSecret: array[32, byte], dhSelf: PrivateKey, dhRemote: PublicKey, inviter: bool = true): Doubleratchet =

info "Initializing Double Ratchet"
info "DH Self: ", dhSelf = dhSelf
info "DH Self public: ", dhSelf = dhSelf.public
info "DH Remote: ", dhRemote = dhRemote
result = Doubleratchet(
dhSelf: dhSelf,
dhRemote: dhRemote,
Expand All @@ -188,5 +203,5 @@ func initDoubleratchet*(sharedSecret: array[32, byte], dhSelf: PrivateKey, dhRem
skippedMessageKeys: initTable[(PublicKey, MsgCount), MessageKey]()
)

if isSending:
if not inviter:
result.dhRatchetSend()