mod_e2e_policy/mod_e2e_policy.lua

400 lines
16 KiB
Lua
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

-- This software is free software: you can redistribute it and/or modify
-- it under the terms of the GNU General Public License as published by
-- the Free Software Foundation, version 3 of the License.
--
-- This software is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU General Public License for more details.
--
-- You should have received a copy of the GNU General Public License
-- along with this software. If not, see <https://www.gnu.org/licenses/>.
local st = require "util.stanza";
local set = require "util.set";
local jid_bare = require "util.jid".bare;
local it = require "util.iterators"
local string_render = require "util.interpolation".new("%b{}", st.xml_escape);
local host = module.host;
-- define allowed values for enum-ish config vars
local enum_scheme = set.new({ "omemo", "otr", "pgp", "pgp_legacy" })
local enum_graceperiod_scope = set.new({ "direct", "group" })
local enum_policy = set.new({ "optional", "required" })
local enum_warn_mechanism = set.new({ "message", "error" })
local store = module:open_store() -- equal to open_store(module.name, "keyvalue")
-- module options
local e2e_policy_accepted_schemes
local e2e_policy_graceperiod
local e2e_policy_graceperiod_scope
local e2e_policy_direct
local e2e_policy_group
local e2e_policy_warn_mechanism
local e2e_policy_whitelist
local e2e_policy_message_plain_optional_direct
local e2e_policy_message_plain_required_direct
local e2e_policy_message_unacceptable_optional_direct
local e2e_policy_message_unacceptable_required_direct
local e2e_policy_message_plain_optional_group
local e2e_policy_message_plain_required_group
local e2e_policy_message_unacceptable_optional_group
local e2e_policy_message_unacceptable_required_group
local e2e_policy_message_graceperiod
-- utilities
function set_len(s)
-- return length of a set
return it.count(s)
end
function ripairs(t)
return it.reverse(ipairs(t))
end
local time_units = { "week", "day", "hour", "minute", "second" }
local time_distances = { second = 1, minute = 60 }
time_distances["hour"] = 60 * time_distances["minute"]
time_distances["day"] = 24 * time_distances["hour"]
time_distances["week"] = 7 * time_distances["day"]
-- No months and years, because they differ too much in length
function extract_time_units(seconds)
local data = {}
local leftover = seconds
for i, unit in ipairs(time_units) do
local distance = time_distances[unit]
if leftover < distance then
data[unit] = 0
else -- we have at least 1 unit
local cleanly_divisible = leftover
leftover = leftover % distance -- number of seconds not divisible by unit
cleanly_divisible = cleanly_divisible - leftover
data[unit] = cleanly_divisible / distance
end
end
return data
end
function pretty_time(n)
if n == 0 then
return "0 seconds"
end
local data = extract_time_units(n)
local skip_head = set.new({})
for i, unit in ipairs(time_units) do
if data[unit] ~= 0 then
break
else
skip_head:add(unit)
end
end
local skip_tail = set.new({})
for i, unit in ripairs(time_units) do
if data[unit] ~= 0 then
break
else
skip_tail:add(unit)
end
end
local display_units = set.new(time_units)
display_units:exclude(skip_head)
display_units:exclude(skip_tail)
display_units_num = set_len(display_units) -- set for some reason isn't a sequence, so #display_units doesn't work
local output = ""
local i = 0 -- keep track how many units we actually output
--for unit in display_units do
for _, unit in ipairs(time_units) do
if display_units:contains(unit) then
i = i + 1
if i > 1 then
if i == display_units_num then
output = output .. " and "
else
output = output .. ", "
end
end
output = output .. tostring(data[unit]) .. " " .. unit
if data[unit] ~= 1 then -- ~= because *only* 1 implies no pluralization i.e. 0 is pluralized
output = output .. "s" -- pluralize unit
end
end
end
return output
end
-- module plumbing
function module.load()
-- load config settings
--
-- this function is automatically called by Prosody's module loader
-- after the module has been loaded.
e2e_policy_accepted_schemes = module:get_option_set("e2e_policy_accepted_schemes", { "omemo", "pgp" }) -- possible values: omemo, otr, pgp and pgp_legacy which E2EE schemes are accepted as secure
e2e_policy_graceperiod = module:get_option_number("e2e_policy_graceperiod", 14 * 24 * 3600) -- grace period in seconds, this is used to escalate the per-user policy from "optional" to "required"
e2e_policy_graceperiod_scope = module:get_option_set("e2e_policy_graceperiod_scope", { "direct" }) -- where to apply graceperiods
e2e_policy_direct = module:get_option_string("e2e_policy_direct", "optional") -- possible values: none, optional and required
e2e_policy_group = module:get_option_string("e2e_policy_group", "optional") -- possible values: none, optional and required
e2e_policy_warn_mechanism = module:get_option_string("e2e_policy_warn_mechanism", "message") -- possible values: message and error - what mechanism to use to tell users about lack of E2ee
e2e_policy_whitelist = module:get_option_set("e2e_policy_whitelist", { }) -- make this module ignore messages sent to and from these JIDs or MUCs
for i, scheme in ipairs(e2e_policy_accepted_schemes) do
if not enum_scheme:contains(scheme) then
error("Invalid value in e2e_policy_accepted_schemes: '" .. scheme .. "'. Valid values are: " .. tostring(enum_scheme))
end
end
for i, scope in ipairs(e2e_policy_graceperiod_scope) do
if not enum_graceperiod_scope:contains(scope) then
error("Invalid value in e2e_policy_graceperiod_scope: '" .. scope .."'. Valid values are " .. tostring(enum_graceperiod_scope))
end
end
if not enum_policy:contains(e2e_policy_direct) then
error("Invalid value for e2e_policy_direct: '" .. e2e_policy_direct .. "'. Valid values are: " .. tostring(enum_policy))
end
if not enum_policy:contains(e2e_policy_group) then
error("Invalid value for e2e_policy_group: '" .. e2e_policy_group .. "'. Valid values are: " .. tostring(enum_policy))
end
if not enum_warn_mechanism:contains(e2e_policy_warn_mechanism) then
error("Invalid value for e2e_policy_warn_mechanism: '" .. e2e_policy_warn_mechanism .. "' Valid values are: " .. tostring(enum_warn_mechanism))
end
e2e_policy_message_plain_optional_direct = module:get_option_string("e2e_policy_message_plain_optional_direct", "Your message to {recipient} was not end-to-end encrypted. For security reasons, using one of the following E2EE schemes is *STRONGLY* recommended: {accepted_schemes} ")
e2e_policy_message_plain_required_direct = module:get_option_string("e2e_policy_message_plain_required_direct", "Your message to {recipient} was not end-to-end encrypted. For security reasons, using one of the following E2EE schemes is *REQUIRED* for conversations on this server: {accepted_schemes} ")
e2e_policy_message_unacceptable_optional_direct = module:get_option_string("e2e_policy_message_unacceptable_optional_direct", "Your message to {recipient} was end-to-end encrypted using the {scheme} scheme, but we recommend using one of the following instead: {accepted_schemes} ")
e2e_policy_message_unacceptable_required_direct = module:get_option_string("e2e_policy_message_unacceptable_required_direct", "Your message to {recipient} was end-to-end encrypted using the {scheme} scheme, but this server *REQUIRES* one of these: {accepted_schemes} ")
e2e_policy_message_plain_optional_group = module:get_option_string("e2e_policy_message_plain_optional_group", e2e_policy_message_plain_optional_direct)
e2e_policy_message_plain_required_group = module:get_option_string("e2e_policy_message_plain_required_group", e2e_policy_message_plain_required_direct)
e2e_policy_message_unacceptable_optional_group = module:get_option_string("e2e_policy_message_unacceptable_optional_group", e2e_policy_message_unacceptable_optional_direct)
e2e_policy_message_unacceptable_required_group = module:get_option_string("e2e_policy_message_unacceptable_required_group", e2e_policy_message_unacceptable_required_direct)
e2e_policy_message_graceperiod = module:get_option_string("e2e_policy_message_graceperiod", "You have {graceperiod_remaining} left before this will be enforced and messages without acceptable E2EE will be discarded.")
end
module:hook_global("config-reloaded", module.load) -- make sure config vars are updated when config is reloaded
-- actual module functionality
function send_warning(session, stanza, scheme, policy, graceperiod_remaining, message)
local placeholder_values = {
recipient = stanza.attr.to,
scheme = scheme,
accepted_schemes = tostring(e2e_policy_accepted_schemes)
}
if graceperiod_remaining then
placeholder_values["graceperiod_remaining"] = pretty_time(graceperiod_remaining)
end
local rendered_message = string_render(message, placeholder_values)
if e2e_policy_warn_mechanism == "message" then
session.send(st.message({ from = host, to = stanza.attr.from, type = "headline" }, rendered_message))
elseif e2e_policy_warn_mechanism == "error" then
if policy == "required" then
session.send(st.error_reply(stanza, "modify", "policy-violation", rendered_message))
elseif policy == "optional" then
session.send(st.error_reply(stanza, "continue", "policy-violation", rendered_message))
end
end
end
function get_e2e_scheme(stanza)
local body = stanza:get_child_text("body");
-- check otr
if body and body:sub(1,4) == "?OTR" then
return "otr";
end
-- check omemo https://xmpp.org/extensions/inbox/omemo.html
if stanza:get_child("encrypted", "eu.siacs.conversations.axolotl") or stanza:get_child("encrypted", "urn:xmpp:omemo:0") then
return "omemo";
end
-- check xep27 pgp https://xmpp.org/extensions/xep-0027.html
if stanza:get_child("x", "jabber:x:encrypted") then
return "pgp_legacy";
end
-- check xep373 pgp (OX) https://xmpp.org/extensions/xep-0373.html
if stanza:get_child("openpgp", "urn:xmpp:openpgp:0") then
return "pgp";
end
return "none"
end
function enforce_policy(event)
-- prepend the servers' JID to the whitelist if we're using the "message"
-- mechanism for warnings. this is done to avoid blocking our own warnings
-- when using the "required" policy.
local jid_whitelist
if e2e_policy_warn_mechanism == "message" then
jid_whitelist = set.new()
jid_whitelist:add(module.host)
for jid in e2e_policy_whitelist do
jid_whitelist:add(jid)
end
else
-- error stanzas won't trigger this function, thus
-- prepending the server JID isn't necessary
jid_whitelist = e2e_policy_whitelist
end
-- check if JID is whitelisted
if jid_whitelist:contains(jid_bare(event.stanza.attr.from)) or jid_whitelist:contains(jid_bare(event.stanza.attr.to)) then
return nil;
end
local body = event.stanza:get_child_text("body");
-- do not warn for status messages
if not body or event.stanza.attr.type == "error" then
return nil;
end
local scheme = get_e2e_scheme(event.stanza)
if e2e_policy_accepted_schemes:contains(scheme) then -- message is encrypted with an accepted E2EE scheme
return nil
end
-- no valid encryption found
local now = os.time()
local user = jid_bare(event.stanza.attr.from)
local graceperiod_expired = false
local graceperiod_remaining
if not e2e_policy_graceperiod_scope:empty() then
userdata, err = store:get(user)
if userdata == nil then
if err then
module:log("error", "Can't get policy information from store for '%s'. Error was: %s", user, err)
else -- no error, just no saved data yet, so create it
userdata = {
first_message = os.time()
}
success, err = store:set(user, userdata)
if not success then
if err then
module:log("error", "Failed to save policy information for '%s'. Error was: %s", user, err)
else
module:log("error", "Failed to save policy information for '%s'. No specific error was returned.", user)
end
-- TODO: return nil?
end
end
elseif now >= userdata["first_message"] + e2e_policy_graceperiod then
graceperiod_expired = true
else
graceperiod_remaining = userdata["first_message"] + e2e_policy_graceperiod - now
end
end
local policy
local message
if scheme == "none" then
if event.stanza.attr.type == "groupchat" then
if e2e_policy_graceperiod_scope:contains("group") then
if graceperiod_expired then
policy = "required"
else
policy = "optional"
end
else
policy = e2e_policy_group
end
if policy == "required" then
message = e2e_policy_message_plain_required_group
else
message = e2e_policy_message_plain_optional_group
end
else
if e2e_policy_graceperiod_scope:contains("direct") then
if graceperiod_expired then
policy = "required"
else
policy = "optional"
end
else
policy = e2e_policy_direct
end
if policy == "required" then
message = e2e_policy_message_plain_required_direct
else
message = e2e_policy_message_plain_optional_direct
end
end
module:log("debug", "Plaintext message from %s!", event.stanza.attr.from)
else -- unacceptable E2EE
if event.stanza.attr.type == "groupchat" then
policy = e2e_policy_group
if policy == "required" then
message = e2e_policy_message_unacceptable_required_group
else
message = e2e_policy_message_unacceptable_optional_group
end
else
policy = e2e_policy_direct
if policy == "required" then
message = e2e_policy_message_unacceptable_required_direct
else
message = e2e_policy_message_unacceptable_optional_direct
end
end
module:log("debug", "Message with unacceptable E2EE (%s) from %s!", scheme, event.stanza.attr.from)
end
if graceperiod_remaining then
message = message .. "\n" .. e2e_policy_message_graceperiod
end
send_warning(event.origin, event.stanza, scheme, policy, graceperiod_remaining, message)
if policy == "required" then
return true -- stops further processing of this message, i.e. blocks it from being sent
end
end
module:hook("pre-message/bare", enforce_policy, 300);
module:hook("pre-message/full", enforce_policy, 300);
module:hook("pre-message/host", enforce_policy, 300);