5 -- a clientmod for minetest that lets people send 1 on 1 encrypted messages
6 -- also has a public interface for other mods
8 -- check out cora's tchat mod, which supports using wisp as a backend
10 -- uses the lua-openssl library by George Zhao: https://github.com/zhaozg/lua-openssl
15 -- send(player, message) - send a message
16 -- register_on_receive(function(message)) - register a receiving callback (includes To: messages), if it returns true the message will not be shown to the player
17 -- register_on_receive_split(function(player, message)) - register_on_receive but player and message are pre split
18 -- register_on_send_split(function(player, message)) - register a sending callback, if it returns true the message will not be sent
21 -- players - list of online players (updated every 2 seconds , when someone may have left, and when a message is queued)
23 -- minetest mod security doesn't work so require() is still disabled while modsec is off
24 -- so this doesnt work without patches (it should tho :])
28 -- in src/script/lua_api/l_util.cpp add the following to ModApiUtil:InitializeClient() below API_FCT(decompress);
30 API_FCT(request_insecure_environment);
33 -- in src/script/cpp_api/s_security.cpp add the following below int thread = getThread(L); in ScriptApiSecurity:initializeSecurityClient()
35 // Backup globals to the registry
36 lua_getglobal(L, "_G");
37 lua_rawseti(L, LUA_REGISTRYINDEX, CUSTOM_RIDX_GLOBALS_BACKUP);
40 -- Recompile Minetest (just using make -j$(nproc) is fine)
44 -- Git clone, make, make install (git repo is https://github.com/zhaozg/lua-openssl)
45 -- # mkdir /usr/lib/lua/5.1
46 -- # mv /usr/lib/lua/openssl.so /usr/lib/lua/5.1
50 -- add wisp to the trusted mods setting in Minetest
53 on joining a game, generate a keypair for ECDH
55 medium is minetest private messages for all conversation
57 alice and bob dont know each other
58 alice introduces herself, giving her ECDH public component to bob (using PEM)
59 bob generates the secret and gives alice his public component
60 alice generates the same secret
62 then at any point alice or bob can talk to the other (for eg, alice talks)
63 alice generates a 256 bit nonce and encrypts her message using AES 256 CBC with the nonce as the initialization vector, sending the nonce and message to bob (both base64 encoded and separated by a space character)
64 bob decrypts her message using AES 256 CBC with the nonce as the initialization vector
65 you can swap alice with bob and vice versa to get what will happen if bob messages alice
67 the key exchanging step is performed whenever alice or bob don't have the other's key
68 the encryption step is performed every time a private encrypted message is sent
70 if a player leaves all players with their public key and other data will forget them, it is important to do this since the keys for a player are not persistent across joining/leaving servers
71 if this was not done alice may use a stale key for bob or vice versa, giving an incorrect shared secret
72 this is not damaging to security, it just wouldn't let them talk
76 if minetest
.request_insecure_environment
== nil then
77 error("Wisp: Minetest scripting patches were not applied, please apply them and recompile Minetest.")
80 local env
= minetest
.request_insecure_environment()
82 error("Wisp: not in trusted mods (secure.trusted_mods), please go into the advanced settings and add wisp (all lowercase).")
85 local openssl
= env
.require("openssl")
90 local function init_settings(setting_table
)
91 for k
, v
in pairs(setting_table
) do
92 if minetest
.settings
:get(k
) == nil then
93 if type(v
) == "boolean" then
94 minetest
.settings
:set_bool(k
, v
)
96 minetest
.settings
:set(k
, v
)
103 wisp_prefix
= "&**&",
104 wisp_curve
= "prime256v1",
105 wisp_cipher
= "aes256",
106 wisp_digest
= "sha256",
108 wisp_whisper
= "msg",
109 wisp_hide_sent
= true,
113 -- players must agree on these
114 local prefix
= minetest
.settings
:get("wisp_prefix")
115 local curve
= minetest
.settings
:get("wisp_curve")
116 local cipher
= minetest
.settings
:get("wisp_cipher")
117 local digest
= minetest
.settings
:get("wisp_digest")
119 local iv_size
= minetest
.settings
:get("wisp_iv_size")
120 local whisper
= minetest
.settings
:get("wisp_whisper")
121 local hide_sent
= minetest
.settings
:get_bool("wisp_hide_sent")
123 local timeout
= tonumber(minetest
.settings
:get("wisp_timeout"))
125 local my_key
= openssl
.pkey
.new("ec", curve
)
126 local my_ec
= my_key
:parse().ec
127 local my_export
= my_key
:get_public():export()
129 local pem_begin
= "-----BEGIN PUBLIC KEY-----\n"
130 local pem_end
= "\n-----END PUBLIC KEY-----\n"
132 my_export
= my_export
:sub(pem_begin
:len() + 1, -pem_end
:len() - 1):gsub("\n", "~")
137 -- convenience aliases
138 local function qsplit(message
)
139 return string.split(message
, " ")
142 local function b64_decode(message
)
143 return minetest
.decode_base64(message
)
146 local function b64_encode(message
)
147 return minetest
.encode_base64(message
)
150 local function in_list(list
, value
)
151 for k
, v
in ipairs(list
) do
159 local function append(list
, item
)
160 list
[#list
+ 1] = item
163 local function popfirst(t
)
173 local function unpack(t
, i
)
174 if type(t
) ~= "table" then
180 return t
[i
], unpack(t
, i
+ 1)
187 local function dm(player
, message
)
188 minetest
.send_chat_message("/" .. whisper
.. " " .. player
.. " " .. message
)
192 local function establish(player
)
193 dm(player
, prefix
.. "I " .. my_export
)
197 local function establish_receive(player
, message
, sendout
)
199 local friend
= friends
[player
]
201 local key
= pem_begin
.. message
:gsub("~", "\n") .. pem_end
203 friend
.pubkey
= openssl
.pkey
.read(key
)
205 friend
.secret
= my_ec
:compute_key(friend
.pubkey
:parse().ec
)
206 friend
.key
= openssl
.digest
.digest(digest
, friend
.secret
, true)
208 if sendout
== true then
209 dm(player
, prefix
.. "R " .. my_export
)
216 local function run_callbacks(list
, params
)
217 for k
, v
in ipairs(list
) do
218 if v(unpack(params
)) then
225 local function message_send(player
, message
, hide_to
, force_send
)
226 local me
= minetest
.localplayer
:get_name()
228 if run_callbacks(wisp
.send_split_callbacks
, {player
, message
}) then
232 -- for displaying the To: stuff
234 local target
= player
238 local display_message
= "To " .. target
.. ": " .. message
240 local callback_value
= run_callbacks(wisp
.receive_callbacks
, display_message
)
241 callback_value
= callback_value
or run_callbacks(wisp
.receive_split_callbacks
, {player
, message
})
243 if not callback_value
then
244 minetest
.display_chat_message(display_message
)
249 local friend
= friends
[player
]
250 if friend
== nil then
254 local nonce
= openssl
.random(iv_size
, true)
255 local enc_message
= openssl
.cipher
.encrypt(cipher
, message
, friend
.key
, nonce
)
256 local final_message
= b64_encode(nonce
) .. " " .. b64_encode(enc_message
)
258 if player
~= me
or force_send
then
259 dm(player
, prefix
.. "E " .. final_message
)
264 local function message_receive(player
, message
)
265 local friend
= friends
[player
]
266 if friend
== nil then
270 local nonce
= b64_decode(qsplit(message
)[1])
271 local enc_message
= b64_decode(qsplit(message
)[2])
272 local dec_message
= openssl
.cipher
.decrypt(cipher
, enc_message
, friend
.key
, nonce
)
273 final_message
= "From " .. player
.. ": " .. dec_message
275 local callback_value
= run_callbacks(wisp
.receive_callbacks
, final_message
)
276 callback_value
= callback_value
or run_callbacks(wisp
.receive_split_callbacks
, {player
, dec_message
})
278 if not callback_value
then
279 minetest
.display_chat_message(final_message
)
284 -- check if a player actually left
285 local function player_left(message
)
286 for player
in message
:gmatch("[^ ]* (.+) left the game.") do
287 wisp
.players
= minetest
.get_player_names()
288 for k
, v
in ipairs(wisp
.players
) do
296 -- check if a message is a PM
297 local function pm(message
)
298 for player
, message
in message
:gmatch(".*rom (.+): (.*)") do
299 return player
, message
305 -- check if a message is encrypted
306 local function encrypted(message
)
307 local split
= string.split(message
, " ")
309 if split
[1] == prefix
then
310 return string.sub(message
, string.len(prefix
) + 2)
314 -- check if a message is 'Message sent.' or similar
315 local function message_sent(message
)
316 return message
== "Message sent."
322 wisp
.receive_callbacks
= {}
323 wisp
.receive_split_callbacks
= {}
324 wisp
.send_split_callbacks
= {}
328 local player_check_epoch
= 0
330 -- message queue, accounts for establishing taking non-zero time
331 -- messages are enqueued and dequeued once they can be sent
334 local function enqueue(player
, message
, hide_to
, force_send
)
339 force_send
= force_send
,
342 wisp
.players
= minetest
.get_player_names()
345 local function dequeue()
348 for k
, v
in ipairs(queue
) do
357 local function peek()
362 function wisp
.send(player
, message
, hide_to
, force_send
)
363 if (player
~= minetest
.localplayer
:get_name() or force_send
) and friends
[player
] == nil then
366 enqueue(player
, message
, hide_to
, force_send
)
369 function wisp
.register_on_receive(func
)
370 append(wisp
.receive_callbacks
, func
)
373 function wisp
.register_on_receive_split(func
)
374 append(wisp
.receive_split_callbacks
, func
)
377 function wisp
.register_on_send_split(func
)
378 append(wisp
.send_split_callbacks
, func
)
384 minetest
.register_on_receiving_chat_message(
386 -- hide Message sent.
387 if hide_sent
and message_sent(message
) then
392 local player
, msg
= pm(message
)
393 if player
and msg
then
395 local split
= qsplit(msg
)
396 local plain
= table.concat(popfirst(split
), " ")
399 if split
[1] == prefix
.. "I" then
400 establish_receive(player
, plain
, true)
402 -- key trade response
403 elseif split
[1] == prefix
.. "R" then
404 establish_receive(player
, plain
)
406 -- encrypted message receive
407 elseif split
[1] == prefix
.. "E" then -- encrypt
408 message_receive(player
, plain
)
413 -- remove friends if they leave
414 local player
= player_left(message
)
416 friends
[player
] = nil
422 minetest
.register_globalstep(
424 if os
.time() > player_check_epoch
+ 2 then
425 wisp
.players
= minetest
.get_player_names()
430 if not in_list(wisp
.players
, peek().player
) then
431 minetest
.display_chat_message("Player " .. p
.player
.. " is not online. If they are please resend the message.")
436 if os
.time() > p
.time
+ timeout
then
437 minetest
.display_chat_message("Player " .. p
.player
.. " is not responsive.")
442 if (p
.player
== minetest
.localplayer
:get_name() and not p
.force_send
) or friends
[p
.player
] then
444 message_send(v
.player
, v
.message
, v
.hide_to
, v
.force_send
)
451 minetest
.register_chatcommand("e", {
453 description
= "Send encrypted whisper to player",
454 func
= function(param
)
455 local player
= qsplit(param
)[1]
456 local message
= table.concat(popfirst(qsplit(param
)), " ")
457 if player
== nil then
458 minetest
.display_chat_message("Player not specified.")
461 wisp
.send(player
, message
)