DataReplicator | Seamless Server-Client Data Sync! (v2.0.0)


Provides an efficient, secure, and developer-friendly way
to manage data flow, letting you focus more on building awesome gameplay.


:sparkles: Why DataReplicator?

  • Simple & Clean API: Focus on your gameplay, not network boilerplate. The intuitive API is easy to learn and a joy to use, making your code cleaner and more maintainable.

  • Secure by Design: Protect your game right out of the box with multiple layers of security: obfuscated keys, optional transport encryption (secured by a post-quantum key exchange), and a smart, reputation-based rate limiter that intelligently handles threats.

  • Built for Performance: Keep your game responsive and your network usage low. Automatic batching, priority queues, and revolutionary delta compression work together to ensure maximum efficiency, even at scale.

  • Battle-Tested & Intelligent: Engineered to be reliable, DataReplicator prevents common race conditions during data loading and can smartly distinguish between a player with network lag and a malicious attacker.


:hammer_and_wrench: Quick Look: Getting Started

  1. Get the Module: Grab it from the Roblox Creator Store.

  2. Place it: Usually in ReplicatedStorage.

  3. Require it:

    • Server: local DataReplicator = require(game.ReplicatedStorage.DataReplicator)
    • Client: local DataReplicator = require(game.ReplicatedStorage.DataReplicator)

:scroll: Super Simple Example (Server sending, Client listening):

Server:

local DataReplicator = require(game.ReplicatedStorage.DataReplicator)

DataReplicator:Create("GameMessage", "Hello from the Server!")
task.wait(5)
DataReplicator:Update("GameMessage", "The server says hi again!")

Client:

local DataReplicator = require(game.ReplicatedStorage.DataReplicator)
local messageSignal = DataReplicator:Listen("GameMessage")

messageSignal:Connect(function(newMessage)
    print("Client received:", newMessage) -- Will print "Hello from the Server!" then "The server says hi again!"
end)

:clapper_board: Video Preview

This quick video demo shows it off with a classic use-case: managing and displaying player stats like cash, level, and experience in real-time.


:books: Want the Full Scoop? Dive into the Docs!

For a complete breakdown of every feature, API details, setup instructions, and more in-depth examples, head over to the official documentation:

:right_arrow: DataReplicator - Full Documentation on GitBook


:speech_balloon: Feedback & Questions?

I’d love to hear what you think! If you have any questions, suggestions, or run into any issues, feel free to reply to this post.

How useful do you find DataReplicator?
  • Looks very useful! I can see myself using this.
  • Seems somewhat useful, might check it out.
  • Interesting, but probably not for my current needs.
  • I’m not sure / Need more information.
0 voters

Hope DataReplicator helps you streamline your projects!
Happy developing!

13 Likes

Nice module! I might use this soon. Here’s a suggestion: Remember UnreliableRemoteEvents? You could implement this and add a parameter of whether is the message very important or could be ignored to reduce traffic.

1 Like

Hey, that’s a really neat suggestion! Using UnreliableRemoteEvents could definitely make things zippier for less crucial updates and lighten the network load, which is great.

For DataReplicator, the main goal is to keep everything reliably synced up, so I’d need to be super careful it doesn’t break that, but it’s a cool thought I’ll keep in my development plan for the module.

1 Like

Another suggestion is adding something like :IsKeyCreated(), just in case it is unsure whether a key has already been created

1 Like

I’m confused over why there is obfuscation in this, especially if it’s only on the server side. Focusing just on the server (because if someone decides to intentionally desync their client, that’s a separate, unrelated issue), if the concern is that a bad actor is sending bad data from the server, aren’t there bigger problems to worry about? Since this module doesn’t support client → server changes anyways, it seems like the obfuscation layer is just an extra step between server → client changes that isn’t blocking bad client input (again - if the idea is to block a bad actor’s access to code run on the server, that’s a separate and much larger issue)

1 Like

That’s a good suggestion! Currently, on the server, if :GetServerData(realKey) returns a non-nil value, you know the key has been created and holds that data. However, if it returns nil, it could mean either the key was never created or it was created with an actual nil value. A dedicated :IsKeyCreated() method would indeed make this distinction explicit, returning true as long as an entry for that key exists internally, regardless of what its value is. That’s a good point for added clarity and definitely something worth considering, thanks for the idea!

Hey, thanks for the thoughtful question. You’ve definitely hit on some good points. You’re absolutely right that if the server itself is compromised and sending bad data, we’ve got much bigger fish to fry, and this obfuscation layer wouldn’t be the defense for that. It’s also true this module focuses on server-to-client replication, so it’s not about validating client input trying to change server data.

The main idea behind the client-specific obfuscated keys is more of a ‘security through obscurity’ measure aimed at the client-side. Instead of an exploiter easily seeing a predictable realKey like ‘PlayerCash’ for data they’re listening to (which could make it simpler to write targeted cheats or sniff specific game states across many users), they’d see a randomized key that’s unique to their session for that data. It’s a small hurdle, not a silver bullet, just to make direct observation and scripting against common data keys a tad less straightforward for someone trying to reverse-engineer things on their end. Hope that clears things up a bit, and great discussion point!

I have a question to this module tho. Is this module optimized and will it worsen game performance?

1 Like

Well yes, this module is designed with optimization in mind, especially with its update batching system, which is a big plus for keeping things running smoothly by reducing network chatter. So for typical use cases, it’s generally very efficient and shouldn’t negatively impact performance significantly. Of course, if you’re replicating extremely large or complex data at a super high frequency, that could be a different story, but for most data sync needs, it’s quite well-behaved.

1 Like

:glowing_star: v2.0.0

Update for DataReplicator (since I have some free time).

This is a significant feature update aimed at providing developers with more powerful tools for security and network performance, especially for larger-scale projects. The focus of this version is to introduce advanced, optional features that enhance the module’s capabilities for handling sensitive data and complex data structures efficiently, while maintaining the simple API of v1.0.0.


What's new?

  • Optional End-to-End Encryption
    You can now add { Encrypted = true } when creating or registering a key. This will automatically establish a secure session key for each client (via ML-KEM post-quantum exchange) and encrypt all data for that key using AEAD (ChaCha20-Poly1305).

  • Delta Compression for Tables
    For large tables, enabling { UseDeltaCompression = true } will now only send the fields that have actually changed on each update, instead of the entire table. This can dramatically reduce bandwidth usage for things like large inventories or stat tables.

  • Update Priority Queues
    A Priority option ("High", "Medium", "Low") can now be set for each data key. The server’s batching system will intelligently process and send higher priority updates before lower priority ones, ensuring critical gameplay data is always timely.

  • Intelligent Rate Limiting
    The rate limiter has been upgraded from a simple counter to a reputation-based Token Bucket system. It is now much better at distinguishing between legitimate network lag and malicious behavior, applying dynamic cooldowns to suspicious actors.

  • Secure Key Generation
    All internal obfuscated keys are now generated using a Cryptographically Secure Pseudo-Random Number Generator (CSPRNG), making them statistically unpredictable and hardening security against network inspection.

  • Key Registration API
    A new server-side function, :RegisterKey(), has been added. This helps prevent race conditions by allowing you to safely declare that a data key is valid before its data has finished loading asynchronously (e.g., from a DataStore).


Documentation & Credits

For a complete breakdown of these new features, updated API references, and in-depth examples, please see the official documentation, which has been fully updated for v2.0.0.

And special thanks to:

As always, feedback and questions are welcome. Happy developing.

2 Likes

Thanks for using the cryptography module! Though end to end encryption means that the server doesn’t even have access to the data. The encryption keys, messages etc are generated by each client and they send their public key to each other (using the server) and then the encrypted messages are passed to each other so all the server receives is encrypted jargon.

So it’s not actually possible to do e2e if you want the server to be able to send or read the data, this is more transport encryption (data encrypted in transit between server and clients)

Proper end to end encryption gives me a headache > https://kerkour.com/signal-protocol-pqxdh-rust

--!strict
--!optimize 2
--!native

local Cryptography = require(game.ReplicatedStorage.Cryptography)

local Hashing = Cryptography.Hashing
local Encryption = Cryptography.Encryption
local Utilities = Cryptography.Utilities
local Verification = Cryptography.Verification
local MlKem = Verification.MlKEM

local function ConcatBuffers(...): buffer
	local Buffers = {...}
	local TotalLength = 0

	for _, Buffer in Buffers do
		TotalLength += buffer.len(Buffer)
	end

	local Result = buffer.create(TotalLength)
	local Offset = 0

	for _, Buffer in Buffers do
		local Length = buffer.len(Buffer)
		buffer.copy(Result, Offset, Buffer, 0, Length)
		Offset += Length
	end

	return Result
end

local function Ed25519SkToX25519(Ed25519SecretKey: buffer): buffer
	return Verification.EdDSA.Convert.ConvertPrivateKey(Ed25519SecretKey)
end

local function Ed25519PkToX25519(Ed25519PublicKey: buffer): buffer
	local X25519Public = Verification.EdDSA.Convert.ConvertPublicKey(Ed25519PublicKey)
	if not X25519Public then
		error("Failed to convert Ed25519 public key to X25519")
	end

	return X25519Public
end

local function Kdf(Dh1: buffer, Dh2: buffer, Dh3: buffer, MlkemSharedSecret: buffer): buffer
	local KdfPrefix = buffer.create(32)
	buffer.fill(KdfPrefix, 0, 0xFF, 32)

	local Input = ConcatBuffers(KdfPrefix, Dh1, Dh2, Dh3, MlkemSharedSecret)
	local Output = Hashing.Blake3.Digest(Input, 32)
	return Utilities.Conversions.FromHex(Output)
end

local function KdfWithOneTime(Dh1: buffer, Dh2: buffer, Dh3: buffer, Dh4: buffer, MlkemSharedSecret: buffer): buffer
	local KdfPrefix = buffer.create(32)
	buffer.fill(KdfPrefix, 0, 0xFF, 32)

	local Input = ConcatBuffers(KdfPrefix, Dh1, Dh2, Dh3, Dh4, MlkemSharedSecret)
	local Output = Hashing.Blake3.Digest(Input, 32)
	return Utilities.Conversions.FromHex(Output)
end

local function KdfRootKey(Key: buffer, SharedSecret: buffer): (buffer, buffer)
	local Input = ConcatBuffers(Key, SharedSecret)
	local Output = Hashing.Blake3.Digest(Input, 64)
	local OutputBuffer = Utilities.Conversions.FromHex(Output)

	local RootKey = buffer.create(32)
	local ChainKey = buffer.create(32)
	buffer.copy(RootKey, 0, OutputBuffer, 0, 32)
	buffer.copy(ChainKey, 0, OutputBuffer, 32, 32)

	return RootKey, ChainKey
end

local function KdfChainKey(Key: buffer): (buffer, buffer)
	local Output = Hashing.Blake3.Digest(Key, 64)
	local OutputBuffer = Utilities.Conversions.FromHex(Output)

	local ChainKey = buffer.create(32)
	local MessageKey = buffer.create(32)
	buffer.copy(ChainKey, 0, OutputBuffer, 0, 32)
	buffer.copy(MessageKey, 0, OutputBuffer, 32, 32)

	return ChainKey, MessageKey
end

export type User = {
	Name: string,

	IdentityPrivateKey: buffer,
	IdentityPublicKey: buffer,

	X25519PrekeyPrivate: buffer,
	X25519PrekeyPublic: buffer,
	X25519PrekeySignature: buffer,

	OneTimePrekeys: {[string]: buffer},
	OneTimePrekeyPublics: {[string]: buffer},

	MlkemPrekeyDecapKey: buffer,
	MlkemPrekeyEncapKey: buffer,
	MlkemPrekeySignature: buffer,

	PqOneTimePrekeys: {[string]: buffer},
	PqOneTimePrekeyPublics: {[string]: buffer},
	PqOneTimePrekeySignatures: {[string]: buffer},

	SendingX25519Private: buffer,
	SendingX25519Public: buffer,
	ReceivingX25519Public: buffer?,

	RootKey: buffer,
	ChainKeySending: buffer, 
	ChainKeyReceiving: buffer,

	SendingCounter: number,
	ReceivingCounter: number,
}

export type PqxdhInitOutput = {
	SecretKey: buffer,
	Message: PqxdhInitMessage,
}

export type PqxdhInitMessage = {
	PeerIdentityPublicKey: buffer,
	EphemeralX25519PublicKey: buffer,
	MlkemCiphertext: buffer,
	OneTimePrekeyId: string?,
	PqOneTimePrekeyId: string?,
}

export type Message = {
	Header: MessageHeader,
	Ciphertext: buffer,
}

export type MessageHeader = {
	X25519PublicKey: buffer,
	Counter: number,
	Nonce: buffer,
}

local User = {}
User.__index = User

function User.New(Name: string): User
	local IdentityPrivateKey = Utilities.CSPRNG.Ed25519Random()
	local IdentityPublicKey = Verification.EdDSA.PublicKey(IdentityPrivateKey)

	local X25519PrekeyRaw = Utilities.CSPRNG.Ed25519Random()
	local X25519PrekeyPrivate = Verification.EdDSA.X25519.Mask(X25519PrekeyRaw)
	local X25519PrekeyPublic = Verification.EdDSA.X25519.PublicKey(X25519PrekeyPrivate)

	local X25519PrekeySignature = Verification.EdDSA.Sign(IdentityPrivateKey, IdentityPublicKey, X25519PrekeyPublic)

	local OneTimePrekeys = {}
	local OneTimePrekeyPublics = {}
	for I = 1, 100 do
		local OneTimePrekeyRaw = Utilities.CSPRNG.Ed25519Random()
		local OneTimePrekeyPrivate = Verification.EdDSA.X25519.Mask(OneTimePrekeyRaw)
		local OneTimePrekeyPublic = Verification.EdDSA.X25519.PublicKey(OneTimePrekeyPrivate)
		local KeyId = `OneTime_{I}`
		OneTimePrekeys[KeyId] = OneTimePrekeyPrivate
		OneTimePrekeyPublics[KeyId] = OneTimePrekeyPublic
	end

	local MlkemPrekeyEncapKey, MlkemPrekeyDecapKey = MlKem.MLKEM_1024.GenerateKeys()

	local MlkemPrekeySignature = Verification.EdDSA.Sign(IdentityPrivateKey, IdentityPublicKey, MlkemPrekeyEncapKey)

	local PqOneTimePrekeys = {}
	local PqOneTimePrekeyPublics = {}
	local PqOneTimePrekeySignatures = {}
	for I = 1, 100 do
		local PqPrekeyEncapKey, PqPrekeyDecapKey = MlKem.MLKEM_1024.GenerateKeys()
		local PqKeyId = `PQ_OneTime_{I}`
		local PqPrekeySignature = Verification.EdDSA.Sign(IdentityPrivateKey, IdentityPublicKey, PqPrekeyEncapKey)

		PqOneTimePrekeys[PqKeyId] = PqPrekeyDecapKey
		PqOneTimePrekeyPublics[PqKeyId] = PqPrekeyEncapKey
		PqOneTimePrekeySignatures[PqKeyId] = PqPrekeySignature
	end

	local SendingX25519Raw = Utilities.CSPRNG.Ed25519Random()
	local SendingX25519Private = Verification.EdDSA.X25519.Mask(SendingX25519Raw)
	local SendingX25519Public = Verification.EdDSA.X25519.PublicKey(SendingX25519Private)

	return {
		Name = Name,

		IdentityPrivateKey = IdentityPrivateKey,
		IdentityPublicKey = IdentityPublicKey,

		X25519PrekeyPrivate = X25519PrekeyPrivate,
		X25519PrekeyPublic = X25519PrekeyPublic,
		X25519PrekeySignature = X25519PrekeySignature,

		OneTimePrekeys = OneTimePrekeys,
		OneTimePrekeyPublics = OneTimePrekeyPublics,

		MlkemPrekeyDecapKey = MlkemPrekeyDecapKey,
		MlkemPrekeyEncapKey = MlkemPrekeyEncapKey,
		MlkemPrekeySignature = MlkemPrekeySignature,

		PqOneTimePrekeys = PqOneTimePrekeys,
		PqOneTimePrekeyPublics = PqOneTimePrekeyPublics,
		PqOneTimePrekeySignatures = PqOneTimePrekeySignatures,

		SendingX25519Private = SendingX25519Private,
		SendingX25519Public = SendingX25519Public,
		ReceivingX25519Public = nil,

		RootKey = buffer.create(32),
		ChainKeySending = buffer.create(32),
		ChainKeyReceiving = buffer.create(32),

		SendingCounter = 0,
		ReceivingCounter = 0,
	} :: User
end

function User.RefillOneTimePrekeys(User: User)
	local CurrentCount = 0
	for _ in User.OneTimePrekeys do
		CurrentCount += 1
	end

	local CurrentPqCount = 0
	for _ in User.PqOneTimePrekeys do
		CurrentPqCount += 1
	end

	if CurrentCount < 10 then
		local BaseIndex = 0
		for Key in User.OneTimePrekeys do
			local Index = tonumber(string.match(Key, "OneTime_(%d+)"))
			if Index and Index > BaseIndex then
				BaseIndex = Index
			end
		end

		for I = BaseIndex + 1, BaseIndex + 50 do
			local OneTimePrekeyRaw = Utilities.CSPRNG.Ed25519Random()
			local OneTimePrekeyPrivate = Verification.EdDSA.X25519.Mask(OneTimePrekeyRaw)
			local OneTimePrekeyPublic = Verification.EdDSA.X25519.PublicKey(OneTimePrekeyPrivate)
			local KeyId = `OneTime_{I}`
			User.OneTimePrekeys[KeyId] = OneTimePrekeyPrivate
			User.OneTimePrekeyPublics[KeyId] = OneTimePrekeyPublic
		end

		print(`Refilled X25519 one-time prekeys for {User.Name}, now has {CurrentCount + 50} keys`)
	end

	if CurrentPqCount < 10 then
		local BasePqIndex = 0
		for Key in User.PqOneTimePrekeys do
			local Index = tonumber(string.match(Key, "PQ_OneTime_(%d+)"))
			if Index and Index > BasePqIndex then
				BasePqIndex = Index
			end
		end

		for I = BasePqIndex + 1, BasePqIndex + 50 do
			local PqPrekeyEncapKey, PqPrekeyDecapKey = MlKem.MLKEM_1024.GenerateKeys()
			local PqKeyId = `PQ_OneTime_{I}`
			local PqPrekeySignature = Verification.EdDSA.Sign(User.IdentityPrivateKey, User.IdentityPublicKey, PqPrekeyEncapKey)

			User.PqOneTimePrekeys[PqKeyId] = PqPrekeyDecapKey
			User.PqOneTimePrekeyPublics[PqKeyId] = PqPrekeyEncapKey
			User.PqOneTimePrekeySignatures[PqKeyId] = PqPrekeySignature
		end

		print(`Refilled ML-KEM one-time prekeys for {User.Name}, now has {CurrentPqCount + 50} keys`)
	end
end

local PQXDH = {}

function PQXDH.Init(Alice: User, Bob: User): PqxdhInitOutput
	local X25519Valid = Verification.EdDSA.Verify(Bob.IdentityPublicKey, Bob.X25519PrekeyPublic, Bob.X25519PrekeySignature)
	if not X25519Valid then
		error("failed to verify X25519 prekey")
	end

	local MlkemValid = Verification.EdDSA.Verify(Bob.IdentityPublicKey, Bob.MlkemPrekeyEncapKey, Bob.MlkemPrekeySignature)
	if not MlkemValid then
		error("failed to verify ML-KEM-1024 prekey")
	end

	local EphemeralX25519Raw = Utilities.CSPRNG.Ed25519Random()
	local EphemeralX25519Private = Verification.EdDSA.X25519.Mask(EphemeralX25519Raw)
	local EphemeralX25519Public = Verification.EdDSA.X25519.PublicKey(EphemeralX25519Private)

	local AvailablePqOneTimeKeys = {}
	for KeyId, _ in Bob.PqOneTimePrekeyPublics do
		table.insert(AvailablePqOneTimeKeys, KeyId)
	end

	local PqPrekeyToUse: buffer
	local PqOneTimePrekeyId: string?

	if #AvailablePqOneTimeKeys > 0 then
		PqOneTimePrekeyId = AvailablePqOneTimeKeys[1]
		local KeyId = PqOneTimePrekeyId :: string

		PqPrekeyToUse = Bob.PqOneTimePrekeyPublics[KeyId]
		local PqPrekeySignature = Bob.PqOneTimePrekeySignatures[KeyId]
		local PqPrekeyValid = Verification.EdDSA.Verify(Bob.IdentityPublicKey, PqPrekeyToUse, PqPrekeySignature)
		if not PqPrekeyValid then
			error("failed to verify PQ one-time prekey")
		end
	else
		PqPrekeyToUse = Bob.MlkemPrekeyEncapKey
		PqOneTimePrekeyId = nil
	end
	
	local RandomMessage = Utilities.CSPRNG.RandomBytes(32)
	local MlkemCiphertext, MlkemSharedSecret = MlKem.MLKEM_1024.Encapsulate(PqPrekeyToUse, RandomMessage)
	if not MlkemCiphertext or not MlkemSharedSecret then
		return error("failed to encapsulate with ML-KEM-1024")
	end

	local AliceIdentitySecretKeyX25519 = Ed25519SkToX25519(Alice.IdentityPrivateKey)
	local BobIdentityPublicKeyX25519 = Ed25519PkToX25519(Bob.IdentityPublicKey)

	local Dh1, _ = Verification.EdDSA.X25519.Exchange(AliceIdentitySecretKeyX25519, Bob.X25519PrekeyPublic)
	local Dh2, _ = Verification.EdDSA.X25519.Exchange(EphemeralX25519Private, BobIdentityPublicKeyX25519)
	local Dh3, _ = Verification.EdDSA.X25519.Exchange(EphemeralX25519Private, Bob.X25519PrekeyPublic)

	local OneTimePrekeyId = nil
	local SecretKey

	local AvailableOneTimeKeys = {}
	for KeyId, _ in Bob.OneTimePrekeyPublics do
		table.insert(AvailableOneTimeKeys, KeyId)
	end

	if #AvailableOneTimeKeys > 0 then
		OneTimePrekeyId = AvailableOneTimeKeys[1]
		local OneTimePrekeyPublic = Bob.OneTimePrekeyPublics[OneTimePrekeyId]

		local Dh4, _ = Verification.EdDSA.X25519.Exchange(EphemeralX25519Private, OneTimePrekeyPublic)
		SecretKey = KdfWithOneTime(Dh1, Dh2, Dh3, Dh4, MlkemSharedSecret)
	else
		SecretKey = Kdf(Dh1, Dh2, Dh3, MlkemSharedSecret)
	end

	local InitMessage: PqxdhInitMessage = {
		PeerIdentityPublicKey = Alice.IdentityPublicKey,
		EphemeralX25519PublicKey = EphemeralX25519Public,
		MlkemCiphertext = MlkemCiphertext,
		OneTimePrekeyId = OneTimePrekeyId,
		PqOneTimePrekeyId = PqOneTimePrekeyId,
	}

	return {
		SecretKey = SecretKey,
		Message = InitMessage,
	}
end

function PQXDH.Complete(Bob: User, Message: PqxdhInitMessage): buffer
	local PqPrekeyToDecapsulate
	if Message.PqOneTimePrekeyId then
		PqPrekeyToDecapsulate = Bob.PqOneTimePrekeys[Message.PqOneTimePrekeyId]
		if not PqPrekeyToDecapsulate then
			print(`PQ one-time prekey {Message.PqOneTimePrekeyId} not found, using last-resort prekey`)
			PqPrekeyToDecapsulate = Bob.MlkemPrekeyDecapKey
		end
	else
		PqPrekeyToDecapsulate = Bob.MlkemPrekeyDecapKey
	end

	local MlkemSharedSecret = MlKem.MLKEM_1024.Decapsulate(PqPrekeyToDecapsulate, Message.MlkemCiphertext)

	local AliceIdentityPublicKeyX25519 = Ed25519PkToX25519(Message.PeerIdentityPublicKey)
	local BobIdentitySecretKeyX25519 = Ed25519SkToX25519(Bob.IdentityPrivateKey)

	local Dh1, _ = Verification.EdDSA.X25519.Exchange(Bob.X25519PrekeyPrivate, AliceIdentityPublicKeyX25519)
	local Dh2, _ = Verification.EdDSA.X25519.Exchange(BobIdentitySecretKeyX25519, Message.EphemeralX25519PublicKey)
	local Dh3, _ = Verification.EdDSA.X25519.Exchange(Bob.X25519PrekeyPrivate, Message.EphemeralX25519PublicKey)

	local SecretKey

	if Message.OneTimePrekeyId then
		local OneTimePrekeyPrivate = Bob.OneTimePrekeys[Message.OneTimePrekeyId]
		if OneTimePrekeyPrivate then
			local Dh4, _ = Verification.EdDSA.X25519.Exchange(OneTimePrekeyPrivate, Message.EphemeralX25519PublicKey)
			SecretKey = KdfWithOneTime(Dh1, Dh2, Dh3, Dh4, MlkemSharedSecret)

			Bob.OneTimePrekeys[Message.OneTimePrekeyId] = nil
			Bob.OneTimePrekeyPublics[Message.OneTimePrekeyId] = nil
			print(`Used and deleted X25519 one-time prekey: {Message.OneTimePrekeyId}`)
		else
			print(`X25519 one-time prekey {Message.OneTimePrekeyId} not found, using standard 3-DH`)
			SecretKey = Kdf(Dh1, Dh2, Dh3, MlkemSharedSecret)
		end
	else
		SecretKey = Kdf(Dh1, Dh2, Dh3, MlkemSharedSecret)
	end

	if Message.PqOneTimePrekeyId then
		if Bob.PqOneTimePrekeys[Message.PqOneTimePrekeyId] then
			Bob.PqOneTimePrekeys[Message.PqOneTimePrekeyId] = nil
			Bob.PqOneTimePrekeyPublics[Message.PqOneTimePrekeyId] = nil
			Bob.PqOneTimePrekeySignatures[Message.PqOneTimePrekeyId] = nil
			print(`Used and deleted ML-KEM one-time prekey: {Message.PqOneTimePrekeyId}`)
		end
	end

	User.RefillOneTimePrekeys(Bob)

	return SecretKey
end

local DoubleRatchet = {}

function DoubleRatchet.InitAlice(Alice: User, SharedKey: buffer, BobX25519PublicKey: buffer)
	Alice.ReceivingX25519Public = BobX25519PublicKey

	local SharedSecret, _ = Verification.EdDSA.X25519.Exchange(Alice.SendingX25519Private, BobX25519PublicKey)

	Alice.RootKey, Alice.ChainKeySending = KdfRootKey(SharedKey, SharedSecret)
end

function DoubleRatchet.InitBob(Bob: User, SharedKey: buffer)
	buffer.copy(Bob.RootKey, 0, SharedKey, 0, 32)
end

function DoubleRatchet.SendMessage(Sender: User, MessageText: string, AdditionalData: buffer): Message
	local NewChainKeySending, MessageKey = KdfChainKey(Sender.ChainKeySending)
	Sender.ChainKeySending = NewChainKeySending

	local Nonce = Utilities.CSPRNG.RandomBytes(12)

	local Header = {
		X25519PublicKey = Sender.SendingX25519Public,
		Counter = Sender.SendingCounter,
		Nonce = Nonce,
	}

	local PlaintextBuffer = buffer.fromstring(MessageText)
	local Ciphertext, Tag = Encryption.AEAD.Encrypt(PlaintextBuffer, MessageKey, Nonce, AdditionalData)
	local CombinedCiphertext = ConcatBuffers(Ciphertext, Tag)

	print(`> {Sender.Name} is sending [{Sender.SendingCounter}]: {MessageText}`)

	local Message = {
		Header = Header,
		Ciphertext = CombinedCiphertext,
	}

	Sender.SendingCounter += 1

	return Message
end

function DoubleRatchet.ReceiveMessage(Receiver: User, Message: Message, AdditionalData: buffer)
	if Receiver.ReceivingX25519Public ~= Message.Header.X25519PublicKey then
		Receiver.ReceivingX25519Public = Message.Header.X25519PublicKey

		Receiver.RootKey, Receiver.ChainKeyReceiving = KdfRootKey(
			Receiver.RootKey,
			(Verification.EdDSA.X25519.Exchange(Receiver.SendingX25519Private, Message.Header.X25519PublicKey))
		)

		local SendingX25519Raw = Utilities.CSPRNG.Ed25519Random()
		Receiver.SendingX25519Private = Verification.EdDSA.X25519.Mask(SendingX25519Raw)
		Receiver.SendingX25519Public = Verification.EdDSA.X25519.PublicKey(Receiver.SendingX25519Private)

		Receiver.RootKey, Receiver.ChainKeySending = KdfRootKey(
			Receiver.RootKey,
			(Verification.EdDSA.X25519.Exchange(Receiver.SendingX25519Private, Message.Header.X25519PublicKey))
		)
	end

	local ChainKeyReceiving, MessageKey = KdfChainKey(Receiver.ChainKeyReceiving)
	Receiver.ChainKeyReceiving = ChainKeyReceiving

	local CiphertextLen = buffer.len(Message.Ciphertext)
	local Ciphertext = buffer.create(CiphertextLen - 16)
	local Tag = buffer.create(16)
	buffer.copy(Ciphertext, 0, Message.Ciphertext, 0, CiphertextLen - 16)
	buffer.copy(Tag, 0, Message.Ciphertext, CiphertextLen - 16, 16)

	local PlaintextBuffer = Encryption.AEAD.Decrypt(Ciphertext, MessageKey, Message.Header.Nonce, Tag, AdditionalData)

	if PlaintextBuffer then
		local MessagePlaintext = buffer.tostring(PlaintextBuffer)
		print(`< {Receiver.Name} received [{Receiver.ReceivingCounter}]: {MessagePlaintext}`)
		Receiver.ReceivingCounter += 1
	else
		error("Failed to decrypt message")
	end
end

local function Demo()
	local Alice = User.New("Alice")
	local Bob = User.New("Bob")

	local OneTimeCount = 0
	for _ in Bob.OneTimePrekeyPublics do
		OneTimeCount += 1
	end

	local PqOneTimeCount = 0
	for _ in Bob.PqOneTimePrekeyPublics do
		PqOneTimeCount += 1
	end

	print(`Bob starts with {OneTimeCount} X25519 one-time prekeys and {PqOneTimeCount} ML-KEM one-time prekeys`)

	local InitResult = PQXDH.Init(Alice, Bob)
	local BobSecretKey = PQXDH.Complete(Bob, InitResult.Message)

	local AliceSecretHex = Utilities.Conversions.ToHex(InitResult.SecretKey)
	local BobSecretHex = Utilities.Conversions.ToHex(BobSecretKey)

	print(`Alice Secret Key: {AliceSecretHex}`)
	print(`Bob Secret Key:   {BobSecretHex}`)

	if AliceSecretHex == BobSecretHex then
		print("PQXDH done")
		print(`Shared Secret: {AliceSecretHex:sub(1, 16)}...`)
		if InitResult.Message.OneTimePrekeyId then
			print(`Used X25519 one-time prekey: {InitResult.Message.OneTimePrekeyId}`)
		else
			print("No X25519 one-time prekey used")
		end
		if InitResult.Message.PqOneTimePrekeyId then
			print(`Used ML-KEM one-time prekey: {InitResult.Message.PqOneTimePrekeyId}`)
		else
			print("Used ML-KEM last-resort prekey")
		end
	else
		error("PQXDH failed")
	end

	local RemainingOneTimeKeys = 0
	for _ in Bob.OneTimePrekeyPublics do
		RemainingOneTimeKeys += 1
	end

	local RemainingPqOneTimeKeys = 0
	for _ in Bob.PqOneTimePrekeyPublics do
		RemainingPqOneTimeKeys += 1
	end

	print(`Bob now has {RemainingOneTimeKeys} X25519 and {RemainingPqOneTimeKeys} ML-KEM one-time prekeys remaining`)

	DoubleRatchet.InitAlice(Alice, InitResult.SecretKey, Bob.SendingX25519Public)
	DoubleRatchet.InitBob(Bob, InitResult.SecretKey)

	local AdditionalData = ConcatBuffers(Alice.IdentityPublicKey, Bob.IdentityPublicKey)

	local Message1 = DoubleRatchet.SendMessage(Alice, "Hello", AdditionalData)
	DoubleRatchet.ReceiveMessage(Bob, Message1, AdditionalData)

	local Message2 = DoubleRatchet.SendMessage(Bob, "World!", AdditionalData)
	DoubleRatchet.ReceiveMessage(Alice, Message2, AdditionalData)

	local Message3 = DoubleRatchet.SendMessage(Alice, "im alice", AdditionalData)
	DoubleRatchet.ReceiveMessage(Bob, Message3, AdditionalData)

	local Message4 = DoubleRatchet.SendMessage(Bob, "im bob", AdditionalData)
	DoubleRatchet.ReceiveMessage(Alice, Message4, AdditionalData)

	for I = 1, 5 do
		local TestAlice = User.New(`Alice{I}`)
		local TestResult = PQXDH.Init(TestAlice, Bob)
		PQXDH.Complete(Bob, TestResult.Message)
		task.wait()
	end

	local FinalRemainingX25519Keys = 0
	for _ in Bob.OneTimePrekeyPublics do
		FinalRemainingX25519Keys += 1
	end

	local FinalRemainingPqKeys = 0
	for _ in Bob.PqOneTimePrekeyPublics do
		FinalRemainingPqKeys += 1
	end

	print(`After 5 more key exchanges, Bob has {FinalRemainingX25519Keys} X25519 and {FinalRemainingPqKeys} ML-KEM one-time prekeys`)

	print("done")
end
Demo()

Also you don’t really need the whole obfuscated key system, I’d just use them directly

1 Like

Hey, thanks a lot for taking the time to explain that. The clarification on Transport Encryption vs E2E is super helpful. I’ll be updating the documentation and forum posts to reflect that, making sure the terminology is accurate to what’s been implemented.

Your suggestion about the obfuscation system makes a lot of sense, and I will consider it in the next update of this module. Thank you for sharing your insights!

1 Like

Back again. Im just curious on how would you compare this to other replication modules such as Packet?