445 lines
12 KiB
Lua
445 lines
12 KiB
Lua
local env = minetest.request_insecure_environment()
|
|
local openssl = env.require("openssl")
|
|
|
|
-- Wisp by system32
|
|
-- CC0/Unlicense 2020
|
|
-- version 1.0
|
|
--
|
|
-- a clientmod for minetest that lets people send 1 on 1 encrypted messages
|
|
-- also has a public interface for other mods
|
|
--
|
|
-- check out cora's tchat mod, which supports using wisp as a backend
|
|
|
|
-- uses the lua-openssl library by George Zhao: https://github.com/zhaozg/lua-openssl
|
|
|
|
-- public interface
|
|
--
|
|
-- Methods
|
|
-- send(player, message) - send a message
|
|
-- 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
|
|
-- register_on_receive_split(function(player, message)) - register_on_receive but player and message are pre split
|
|
-- register_on_send_split(function(player, message)) - register a sending callback, if it returns true the message will not be sent
|
|
--
|
|
-- Properties
|
|
-- players - list of online players (updated every 2 seconds , when someone may have left, and when a message is queued)
|
|
|
|
-- minetest mod security doesn't work so require() is still disabled while modsec is off
|
|
-- so this doesnt work without patches (it should tho :])
|
|
|
|
-- PATCHING MINETEST
|
|
--
|
|
-- in src/script/lua_api/l_util.cpp add the following to ModApiUtil:InitializeClient() below API_FCT(decompress);
|
|
--[[
|
|
API_FCT(request_insecure_environment);
|
|
--]]
|
|
--
|
|
-- in src/script/cpp_api/s_security.cpp add the following below int thread = getThread(L); in ScriptApiSecurity:initializeSecurityClient()
|
|
--[[
|
|
// Backup globals to the registry
|
|
lua_getglobal(L, "_G");
|
|
lua_rawseti(L, LUA_REGISTRYINDEX, CUSTOM_RIDX_GLOBALS_BACKUP);
|
|
--]]
|
|
--
|
|
-- Recompile Minetest (just using make -j$(nproc) is fine)
|
|
|
|
-- INSTALLING OPENSSL
|
|
--
|
|
-- Git clone, make, make install (git repo is https://github.com/zhaozg/lua-openssl)
|
|
-- # mkdir /usr/lib/lua/5.1
|
|
-- # mv /usr/lib/lua/openssl.so /usr/lib/lua/5.1
|
|
|
|
-- ADDING TO TRUSTED
|
|
--
|
|
-- add wisp to the trusted mods setting in Minetest
|
|
|
|
--[[ protocol:
|
|
on joining a game, generate a keypair for ECDH
|
|
|
|
medium is minetest private messages for all conversation
|
|
|
|
alice and bob dont know each other
|
|
alice introduces herself, giving her ECDH public component to bob (using PEM)
|
|
bob generates the secret and gives alice his public component
|
|
alice generates the same secret
|
|
|
|
then at any point alice or bob can talk to the other (for eg, alice talks)
|
|
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)
|
|
bob decrypts her message using AES 256 CBC with the nonce as the initialization vector
|
|
you can swap alice with bob and vice versa to get what will happen if bob messages alice
|
|
|
|
the key exchanging step is performed whenever alice or bob don't have the other's key
|
|
the encryption step is performed every time a private encrypted message is sent
|
|
|
|
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
|
|
if this was not done alice may use a stale key for bob or vice versa, giving an incorrect shared secret
|
|
this is not damaging to security, it just wouldn't let them talk
|
|
--]]
|
|
|
|
|
|
-- private stuff
|
|
|
|
local function init_settings(setting_table)
|
|
for k, v in pairs(setting_table) do
|
|
if minetest.settings:get(k) == nil then
|
|
if type(v) == "boolean" then
|
|
minetest.settings:set_bool(k, v)
|
|
else
|
|
minetest.settings:set(k, v)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
init_settings({
|
|
wisp_prefix = "&**&",
|
|
wisp_curve = "prime256v1",
|
|
wisp_cipher = "aes256",
|
|
wisp_digest = "sha256",
|
|
wisp_iv_size = 8,
|
|
wisp_whisper = "msg",
|
|
wisp_hide_sent = true
|
|
})
|
|
|
|
-- players must agree on these
|
|
local prefix = minetest.settings:get("wisp_prefix")
|
|
local curve = minetest.settings:get("wisp_curve")
|
|
local cipher = minetest.settings:get("wisp_cipher")
|
|
local digest = minetest.settings:get("wisp_digest")
|
|
|
|
local iv_size = minetest.settings:get("wisp_iv_size")
|
|
local whisper = minetest.settings:get("wisp_whisper")
|
|
local hide_sent = minetest.settings:get_bool("wisp_hide_sent")
|
|
|
|
local my_key = openssl.pkey.new("ec", curve)
|
|
local my_ec = my_key:parse().ec
|
|
local my_export = my_key:get_public():export()
|
|
|
|
local pem_begin = "-----BEGIN PUBLIC KEY-----\n"
|
|
local pem_end = "\n-----END PUBLIC KEY-----\n"
|
|
|
|
my_export = my_export:sub(pem_begin:len() + 1, -pem_end:len() - 1):gsub("\n", "~")
|
|
|
|
local friends = {}
|
|
|
|
|
|
-- convenience aliases
|
|
local function qsplit(message)
|
|
return string.split(message, " ")
|
|
end
|
|
|
|
local function b64_decode(message)
|
|
return minetest.decode_base64(message)
|
|
end
|
|
|
|
local function b64_encode(message)
|
|
return minetest.encode_base64(message)
|
|
end
|
|
|
|
local function in_list(list, value)
|
|
for k, v in ipairs(list) do
|
|
if v == value then
|
|
return true
|
|
end
|
|
end
|
|
return false
|
|
end
|
|
|
|
local function append(list, item)
|
|
list[#list + 1] = item
|
|
end
|
|
|
|
local function popfirst(t)
|
|
local out = {}
|
|
|
|
for i = 2, #t do
|
|
out[#out + 1] = t[i]
|
|
end
|
|
|
|
return out
|
|
end
|
|
|
|
local function unpack(t, i)
|
|
if type(t) ~= "table" then
|
|
return t
|
|
end
|
|
|
|
i = i or 1
|
|
if t[i] ~= nil then
|
|
return t[i], unpack(t, i + 1)
|
|
end
|
|
end
|
|
|
|
|
|
-- key trading
|
|
|
|
local function dm(player, message)
|
|
minetest.send_chat_message("/" .. whisper .. " " .. player .. " " .. message)
|
|
end
|
|
|
|
-- initialize
|
|
local function establish(player)
|
|
dm(player, prefix .. "I " .. my_export)
|
|
end
|
|
|
|
-- receiving
|
|
local function establish_receive(player, message, sendout)
|
|
friends[player] = {}
|
|
local friend = friends[player]
|
|
|
|
local key = pem_begin .. message:gsub("~", "\n") .. pem_end
|
|
|
|
friend.pubkey = openssl.pkey.read(key)
|
|
|
|
friend.secret = my_ec:compute_key(friend.pubkey:parse().ec)
|
|
friend.key = openssl.digest.digest(digest, friend.secret, true)
|
|
|
|
if sendout == true then
|
|
dm(player, prefix .. "R " .. my_export)
|
|
end
|
|
end
|
|
|
|
|
|
-- encryption
|
|
|
|
local function run_callbacks(list, params)
|
|
for k, v in ipairs(list) do
|
|
if v(unpack(params)) then
|
|
return true
|
|
end
|
|
end
|
|
end
|
|
|
|
-- encrypt and send
|
|
local function message_send(player, message, hide_to, force_send)
|
|
local me = minetest.localplayer:get_name()
|
|
|
|
if run_callbacks(wisp.send_split_callbacks, {player, message}) then
|
|
return
|
|
end
|
|
|
|
-- for displaying the To: stuff
|
|
if not hide_to then
|
|
local target = player
|
|
if target == me then
|
|
target = "Yourself"
|
|
end
|
|
local display_message = "To " .. target .. ": " .. message
|
|
|
|
local callback_value = run_callbacks(wisp.receive_callbacks, display_message)
|
|
callback_value = callback_value or run_callbacks(wisp.receive_split_callbacks, {player, message})
|
|
|
|
if not callback_value then
|
|
minetest.display_chat_message(display_message)
|
|
end
|
|
end
|
|
|
|
-- actual encryption
|
|
local friend = friends[player]
|
|
if friend == nil then
|
|
return
|
|
end
|
|
|
|
local nonce = openssl.random(iv_size, true)
|
|
local enc_message = openssl.cipher.encrypt(cipher, message, friend.key, nonce)
|
|
local final_message = b64_encode(nonce) .. " " .. b64_encode(enc_message)
|
|
|
|
if player ~= me or force_send then
|
|
dm(player, prefix .. "E " .. final_message)
|
|
end
|
|
end
|
|
|
|
-- decrypt and show
|
|
local function message_receive(player, message)
|
|
local friend = friends[player]
|
|
if friend == nil then
|
|
return
|
|
end
|
|
|
|
local nonce = b64_decode(qsplit(message)[1])
|
|
local enc_message = b64_decode(qsplit(message)[2])
|
|
local dec_message = openssl.cipher.decrypt(cipher, enc_message, friend.key, nonce)
|
|
final_message = "From " .. player .. ": " .. dec_message
|
|
|
|
local callback_value = run_callbacks(wisp.receive_callbacks, final_message)
|
|
callback_value = callback_value or run_callbacks(wisp.receive_split_callbacks, {player, dec_message})
|
|
|
|
if not callback_value then
|
|
minetest.display_chat_message(final_message)
|
|
end
|
|
end
|
|
|
|
|
|
-- check if a player actually left
|
|
local function player_left(message)
|
|
for player in message:gmatch("[^ ]* (.+) left the game.") do
|
|
wisp.players = minetest.get_player_names()
|
|
for k, v in ipairs(wisp.players) do
|
|
if v == player then
|
|
return player
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- check if a message is a PM
|
|
local function pm(message)
|
|
for player, message in message:gmatch(".*rom (.+): (.*)") do
|
|
return player, message
|
|
end
|
|
|
|
return nil, nil
|
|
end
|
|
|
|
-- check if a message is encrypted
|
|
local function encrypted(message)
|
|
local split = string.split(message, " ")
|
|
|
|
if split[1] == prefix then
|
|
return string.sub(message, string.len(prefix) + 2)
|
|
end
|
|
end
|
|
|
|
-- check if a message is 'Message sent.' or similar
|
|
local function message_sent(message)
|
|
return message == "Message sent."
|
|
end
|
|
|
|
|
|
|
|
wisp = {}
|
|
wisp.receive_callbacks = {}
|
|
wisp.receive_split_callbacks = {}
|
|
wisp.send_split_callbacks = {}
|
|
wisp.players = {}
|
|
|
|
|
|
local player_check_epoch = 0
|
|
|
|
-- message queue, accounts for establishing taking non-zero time
|
|
-- messages are enqueued and dequeued once they can be sent
|
|
local queue = {}
|
|
|
|
local function enqueue(player, message, hide_to, force_send)
|
|
append(queue, {
|
|
player = player,
|
|
message = message,
|
|
hide_to = hide_to,
|
|
force_send = force_send
|
|
})
|
|
wisp.players = minetest.get_player_names()
|
|
end
|
|
|
|
local function dequeue()
|
|
local new_queue = {}
|
|
local out = queue[1]
|
|
for k, v in ipairs(queue) do
|
|
if k ~= 1 then
|
|
append(new_queue, v)
|
|
end
|
|
end
|
|
queue = new_queue
|
|
return out
|
|
end
|
|
|
|
local function peek()
|
|
return queue[1]
|
|
end
|
|
|
|
|
|
function wisp.send(player, message, hide_to, force_send)
|
|
if (player ~= minetest.localplayer:get_name() or force_send) and friends[player] == nil then
|
|
establish(player)
|
|
end
|
|
enqueue(player, message, hide_to, force_send)
|
|
end
|
|
|
|
function wisp.register_on_receive(func)
|
|
append(wisp.receive_callbacks, func)
|
|
end
|
|
|
|
function wisp.register_on_receive_split(func)
|
|
append(wisp.receive_split_callbacks, func)
|
|
end
|
|
|
|
function wisp.register_on_send_split(func)
|
|
append(wisp.send_split_callbacks, func)
|
|
end
|
|
|
|
|
|
-- glue
|
|
|
|
minetest.register_on_receiving_chat_message(
|
|
function(message)
|
|
-- hide Message sent.
|
|
if hide_sent and message_sent(message) then
|
|
return true
|
|
end
|
|
|
|
-- if its a PM
|
|
local player, msg = pm(message)
|
|
if player and msg then
|
|
|
|
local split = qsplit(msg)
|
|
local plain = table.concat(popfirst(split), " ")
|
|
|
|
-- initial key trade
|
|
if split[1] == prefix .. "I" then
|
|
establish_receive(player, plain, true)
|
|
return true
|
|
-- key trade response
|
|
elseif split[1] == prefix .. "R" then
|
|
establish_receive(player, plain)
|
|
return true
|
|
-- encrypted message receive
|
|
elseif split[1] == prefix .. "E" then -- encrypt
|
|
message_receive(player, plain)
|
|
return true
|
|
end
|
|
end
|
|
|
|
-- remove friends if they leave
|
|
local player = player_left(message)
|
|
if player then
|
|
friends[player] = nil
|
|
end
|
|
end
|
|
)
|
|
|
|
|
|
minetest.register_globalstep(
|
|
function()
|
|
if os.time() > player_check_epoch + 2 then
|
|
wisp.players = minetest.get_player_names()
|
|
end
|
|
|
|
local p = peek()
|
|
if p then
|
|
if not in_list(wisp.players, peek().player) then
|
|
minetest.display_chat_message("Player " .. peek().player .. " is not online. If they are please resend the message.")
|
|
dequeue()
|
|
return
|
|
end
|
|
|
|
if (p.player == minetest.localplayer:get_name() and not p.force_send) or friends[p.player] then
|
|
local v = dequeue()
|
|
message_send(v.player, v.message, v.hide_to, v.force_send)
|
|
end
|
|
end
|
|
end
|
|
)
|
|
|
|
|
|
minetest.register_chatcommand("e", {
|
|
params = "<player>",
|
|
description = "Send encrypted whisper to player",
|
|
func = function(param)
|
|
local player = qsplit(param)[1]
|
|
local message = table.concat(popfirst(qsplit(param)), " ")
|
|
if player == nil then
|
|
minetest.display_chat_message("Player not specified.")
|
|
return
|
|
end
|
|
wisp.send(player, message)
|
|
end
|
|
})
|