1
0
Fork 0
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

705 lines
24 KiB

-- mod_auth_anon v0.1
-- Copyright (C) 2011 Matthieu Lalonde
--
-- All rights reserved.
--
-- Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
--
-- * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
-- * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
-- * Neither the name of Tobias Markmann nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
--
-- THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
-- TODO: Minimum length username+pass
-- TODO: Add pretty nicknames to users (must be random and unique!)
-- TODO: Drop client that are idle for X time in order to help protect their privacy on Tor
-- DONE: On new signup, publish to the roster so no login cycle is needed
-- TODO: When a new user is pushed to the roster of online users, they don't seem to be authorized (or at least presence is not pushed)
-- TODO?: Change all Version IQ to INFOXMPP-APPROVED
-- TODO?: Add <sub xmlns='urn:xmpp:features:pre-approval'/> to stream negociation
-- TODO?: Change to a whitelisting system for IQ filtering in case there is more than bytestreams and jingle/content/transport that leak ips
-- TODO?: Move the datastore callbacks to the new mechanism?! (Need to figure out what that mechanism is but callbacks are deprecated apparently)
-- TODO?: Block locale from leaking (done in core/stanza_router.lua but needs to move to filters in here see: http://prosody-modules.googlecode.com/hg/mod_smacks/mod_smacks.lua )
local mod_auth_anon_changelog = {
{0.3, [[
]]
},
{0.2, [[
- Added: Changelog and version
- Added: Generated user nicknames
- Fixed: Bytestreams filtering cleaned up and fixed for Psi
- Fixed: resource binding in Psi]]},
{0.1, [[
- First versions before the changelog, a brief of current functionalities
- Anonymous login with user ids based on hmac:sha256(salt, username+password),
once logged in a user will receive their unique hashed username, resources are also anonymized.
Users exist so long as they login every X days (default 1 day);
after a user expires any of its stored data is deleted.
- Automatic roster of all current users
- Offline messages permitted only for encrypted messages (detects OTR, RSA and x:encrypted GPG)
- Filtering of bytestream and jingle (at least libpurple) negotiation leaking a users IP
- Filtering of a user's locale leaked by some clients (namely Tkabber)]]}
}
local hmac_sha256 = require "util.hmac".sha256
local hashes = require "util.hashes"
local uuid_generate = require "util.uuid".generate
local datamanager = require "util.datamanager"
local usermanager_create_user = require "core.usermanager".create_user
local json = require "util.json"
local st = require "util.stanza"
require "luasql.sqlite3"
local sqlite3 = luasql.sqlite3()
local connection
local resolve_relative_path = require "core.configmanager".resolve_relative_path
local sm_bind_resource = require "core.sessionmanager".bind_resource
local full_sessions = full_sessions
local data_path = (prosody and prosody.paths and prosody.paths.data) or "."
local xmlns_bind ='urn:ietf:params:xml:ns:xmpp-bind'
local module_host = module:get_host()
local params = module:get_option("anons")
if not params then -- Set defaults
params = {}
end
params.expiry_time = (params.expiry_time or 1) -- Days
params.salt = (params.salt or "iamsalt")
local expiry_time = params.expiry_time * 24 * 3600
local name_list_path = (prosody and prosody.paths and prosody.paths.config) or "."
local function open_list(file)
local list = {}
local fd = assert(io.open(file, "r"))
for line in fd:lines() do
table.insert(list, line)
end
return list
end
local animals = open_list(resolve_relative_path(name_list_path, "names/animals.list"))
local colors = open_list(resolve_relative_path(name_list_path, "names/colors.list"))
local locations = open_list(resolve_relative_path(name_list_path, "names/locations.list"))
local adjectives = open_list(resolve_relative_path(name_list_path, "names/adjectives.list"))
function round(num, idp)
return tonumber(string.format("%." .. (idp or 0) .. "f", num))
end
local function tchelper(first, rest)
return string.upper(first) .. string.lower(rest)
end
local function capitalize(str)
return string.gsub(str, "(%a)([%w_']*)", tchelper)
end
local function trim(str)
return string.gsub(str, "^%s*(.-)%s*$", "%1")
end
local function num_superscript(nbr)
local str_nbr = tostring(nbr):sub(-1)
local suffix = ""
if string.len(tostring(nbr)) > 1 and tostring(nbr):sub(-2) == "13" then
suffix = "th"
elseif str_nbr == "1" then
suffix = "st"
elseif str_nbr == "2" then
suffix = "nd"
elseif str_nbr == "3" then
suffix = "rd"
else
suffix = "th"
end
return suffix
end
local function get_hash_name(hash)
local number = tonumber(string.format("0x%s", hash))
local animal = math.fmod(number, table.getn(animals)) +1
number = round(number / table.getn(animals))
if number == 0 then
return capitalize(trim(animals[animal]))
end
local color = math.fmod(number, table.getn(colors)) + 1
number = round(number / table.getn(colors))
if number == 0 then
return capitalize(trim(colors[color])) .. " " .. capitalize(trim(animals[animal]))
end
local location = math.fmod(number, table.getn(locations)) + 1
number = round(number / table.getn(locations))
if number == 0 then
return capitalize(trim(colors[color])) .. " " .. capitalize(trim(animals[animal])) .. " of " .. capitalize(trim(locations[location]))
end
local adjective = math.fmod(number, table.getn(adjectives)) + 1
number = round(number / table.getn(adjectives))
if number == 0 then
return capitalize(trim(colors[color])) .. " " .. capitalize(trim(animals[animal])) .. " `The " .. capitalize(trim(adjectives[adjective])) .. "` of " .. capitalize(trim(locations[location]))
end
local str_nbr = "The " .. number .. num_superscript(number)
return capitalize(trim(colors[color])) ..
" " .. capitalize(trim(animals[animal])) ..
" " .. str_nbr ..
" `The " .. capitalize(trim(adjectives[adjective])) ..
"` of " .. capitalize(trim(locations[location]))
end
-- URL Encode
local function encode(s)
return s and (s:gsub("%W", function (c) return string.format("%%%02x", c:byte()); end))
end
-- Initiate SQL connections
local function connect()
local database = resolve_relative_path(data_path, (module_host and encode(module_host)) .. "/anons.sqlite")
assert(datamanager.getpath("", module_host, "", "", true))
local dbh = assert(sqlite3:connect(database))
if not dbh then
module:log("error", "Database connection failed: %s", tostring(err))
return nil
end
module:log("debug", "Successfully connected to database")
dbh:setautocommit(true);
connection = dbh;
return connection;
end
-- Drop SQL Connection
local function disconnect()
connection:close();
sqlite3:close();
end
-- Create anon table
local function create_table()
local res = assert(connection:execute[[
CREATE TABLE IF NOT EXISTS `anon_users` (TEXT, `user` TEXT, `name` TEXT, `last` TIMESTAMP, `stores` TEXT);
]]);
if not res then
module:log("error", "Failed to create SQLite Anon Database");
else
module:log("info", "Initialized new %s database with anon_users", "SQLite");
local res = connection:execute[[
CREATE INDEX IF NOT EXISTS `anon_users_index` ON `anon_users` (`user`, `last`, `name`);
]];
if not res then
module:log("warn", "Failed to create anon indexes, lookups may not be optimised");
end
return true;
end
end
-- Lookup a user
local function is_anon_user(username, fields)
if fields == nil then
fields = "`user`"
end
local sql = string.format("SELECT %s FROM `anon_users` WHERE user = %q LIMIT 1;", fields, username)
local res = connection:execute(sql)
if not res then
return nil
else
return res:fetch({}, "a");
end
end
-- Log a user datastore so we can purge them later on
local function log_user_datastore(username, datastore)
local user_result = is_anon_user(username, "`user`, `stores`")
if user_result ~= nil then
local user_stores = json.decode(user_result.stores)
if not user_stores[datastore] then
user_stores[datastore] = true
local sql_str = "UPDATE `anon_users` SET stores = '%s', last = %q WHERE user = %q;";
local sql = string.format(sql_str, json.encode(user_stores), tostring(os.time()), username);
if not connection:execute(sql) then
module:log("error", "Failed to write stores %q status for %q: %s", datastore, username, sql);
return nil
else
return true
end
else
return true
end
else
module:log("debug", "User %q doesn't exist, not storing datastore!", username)
return nil
end
end
-- Purges expired users by deleting any datastores they might have and their database entry
local function purge_users()
-- Fetch all the stale users
sql = string.format("SELECT * FROM `anon_users` WHERE last < %s;", (os.time() - expiry_time));
local res = connection:execute(sql);
if not res then
module:log("error", "Error fetching stale users: %s", sql);
return nil;
end
local row = res:fetch ({}, "a");
local errors = false;
-- There are no expired users
if not row then
return true;
end
while row do
-- First make sure we update any expired logins that are still connected
if prosody.bare_sessions[row.user .. "@" .. module_host] ~= nil then
sql = string.format("UPDATE `anon_users` SET last = %s WHERE user = %q;",
tostring(os.time()), row.user);
if not connection:execute(sql) then
errors = true;
end
-- Otherwise we delete the user's datastores
else
local user_stores = json.decode(row.stores);
local str_stores = ""
if string.len(row.stores) > 0 then
for t, v in pairs(user_stores) do
-- Remove stores and list stores
datamanager.store(row.user, module_host, tostring(t), nil)
datamanager.list_store(row.user, module_host, tostring(t), nil)
str_stores = str_stores .. tostring(t) .. " "
end
module:log("debug", "Removed datastores %q for stale user %q", str_stores, row.user)
end
end
row = res:fetch (row, "a")
end
-- Deleting any expired users left in the DB
if not errors then
sql = string.format("DELETE FROM `anon_users` WHERE last < %s;", (os.time() - expiry_time));
res = connection:execute(sql)
if not res then
module:log("error", "Failed to purge anon users, check the database!")
else
module:log("info", "Purged %s user(s) from anons!", res)
end
return true
else
module:log("error", "There was an error updating current expired users, purging failed!")
end
end
-- Log an anon to the anon DB and clean the old ones.
local function log_user(username)
local sql = string.format("SELECT `user` FROM `anon_users` WHERE user = %q;", username);
local res = connection:execute(sql);
-- The entry doesn't exist, let's create it
if not res:fetch() then
local user_longname = get_hash_name(username)
sql = string.format("INSERT INTO `anon_users` (user, last, name, stores) VALUES (%q, %s, %q, \"{}\");",
username, os.time(), user_longname);
module:log("debug", "Inserting new anon %s", username);
-- The user already exists, update his last login time
else
sql = string.format("UPDATE `anon_users` SET last = %s WHERE user = %q;", os.time(), username);
module:log("debug", "Updating current anon %s", username);
end
if not connection:execute(sql) then
module:log("error", "Failed to write anon login to database: %s", sql)
else -- Let's purge the old users here
purge_users()
end
end
-- Inject ALL THE anons into the roster
local function inject_roster_contacts(username, host, roster)
local res = connection:execute("SELECT `user`, `name` FROM `anon_users`;");
local row = res:fetch ({}, "a")
while row do
if username ~= row.user then
local jid = row.user .. "@" .. module_host;
module:log("debug", "Adding user %s to roster %s.", jid, username);
if not roster[jid] then roster[jid] = {}; end
roster[jid].subscription = "both";
roster[jid].published = true;
roster[jid].name = row.name;
roster[jid].persist = false;
roster[jid].groups = { [module_host] = true };
end
row = res:fetch (row, "a")
end
roster[module_host] = {}
roster[module_host].subscription = "both"
roster[module_host].published = true
roster[module_host].name = module_host
roster[module_host].persist = false
roster[module_host].groups = { [module_host] = true }
end
local function push_roster_contact(username, full_jid)
local user_longname = get_hash_name(username)
local roster_attr = {jid = username.."@"..module_host, name = user_longname,
subscription = "both", approved = "true"}
module:log("debug", "Sending new roster item for user: %s", username)
for _, session in pairs(prosody.bare_sessions) do
if _ ~= username.."@"..module_host then
for __, current_session in pairs(session.sessions) do
local roster_item =
st.iq({to = current_session.full_jid, type = "set"})
:tag("query", {xmlns = "jabber:iq:roster", ver = os.time()})
:tag("item", roster_attr)
:tag("group"):text(module_host)
:up()
:up()
core_route_stanza(module_host, roster_item)
end
end
end
end
-- Catching the remove requests for data to block roster access
local function handle_datamanager(username, host, datastore, data)
if host ~= module_host then
return false;
end
if datastore == "roster" then
-- Don't allow users to actually save rosters
return false;
else
-- Register all datastores for a user so we can purge them later
if data ~= nil then
if log_user_datastore(username, datastore) ~= nil then
return username, host, datastore, data;
else
return false;
end
else
return username, host, datastore, data;
end
end
end
-- Anon Auth Provider, let ALL THE anons in!
module:add_item("auth-provider", {
name = module.name:gsub("^auth_",""),
test_password = function() return true end,
user_exists = function(node, host) return (is_anon_user(node) ~= nil) end,
get_sasl_handler = function () return {
mechanisms = function() return { PLAIN = true, } end,
plain = function(self, message)
if string.len(message) < 18 then
return "failure", "malformed-request", "Invalid username or password must be 18 characters or more.";
else
self.username = hmac_sha256(params.salt, message:match("%z(.*)$"), true);
self.username = self.username:sub(-10);
return "success"
end
end,
select = function(self, mech) self.process = self[mech:lower()] return true end,
clean_clone = function(self) self.username = nil; self.process = nil; return self end,
} end,
})
-- Override the IQ Bind hook from saslauth in order to send the welcome message
module:hook("resource-bind", function(event)
local session = event.session
local bare_jid = session.username .. "@" .. session.host
if is_anon_user(session.username) == nil then
prosody.bare_sessions[bare_jid].is_new_user = true
end
log_user(session.username)
local user_longname = is_anon_user(session.username, "`name`")
user_longname = user_longname.name
local welcome_stanza =
st.message({ to = bare_jid, from = session.host })
:tag("body")
:text(string.format("Welcome in %s, your username is: %s@%s",
user_longname, session.username, session.host))
:up()
module:log("debug", "Welcomed user %s %q", session.full_jid, tostring(welcome_stanza))
core_route_stanza(session.host, welcome_stanza)
return nil -- Go on with the original hook if there is any
end, 9000) -- Priority over 9000, nothing should be over 9000, really!
module:hook("presence/host", function(event)
local origin, stanza = event.origin, event.stanza;
-- Catch the fist time a user comes online
if stanza and stanza.attr and stanza.attr.type == "probe" and stanza.attr.to == module_host
and prosody.bare_sessions[origin.username.."@"..origin.host].is_new_user ~= nil then
module:log("debug", "***** PRESENCE PUSHING NEW USER TO USERS ******")
prosody.bare_sessions[origin.username.."@"..origin.host].is_new_user = nil
push_roster_contact(origin.username, origin.full_jid)
end
return nil
end, 9000)
-- Override the IQ Bind hook from saslauth in order to anonymize resource
module:hook("iq/self/urn:ietf:params:xml:ns:xmpp-bind:bind", function(event)
local origin, stanza = event.origin, event.stanza;
if stanza.attr.type == "set" then
local resource_tag = stanza.tags[1]:child_with_name("resource")
local hashed_resource = tostring(hmac_sha256((resource_tag and #resource_tag.tags == 0 and resource_tag[1] or "wearelegions"), (resource_tag and #resource_tag.tags == 0 and resource_tag[1] or uuid_generate()), true)):sub(-16);
if resource_tag == nil then
stanza.tags[1]:tag("resource"):text(hashed_resource);
else
stanza.tags[1]:child_with_name("resource")[1] = hashed_resource
end
end
return nil
end, 9000) -- Priority over 9000, nothing should be over 9000, really!
module:hook("iq/full", function(data)
-- IQ to full JID recieved
local origin, stanza = data.origin, data.stanza;
local xmlns_bytestream = "http://jabber.org/protocol/bytestreams"
-- Sanitize bytestreams
if stanza:get_child("query", xmlns_bytestream)
and stanza:get_child("query", xmlns_bytestream):get_child("streamhost") then
local sanitize_streamhost = function(tag)
if tag.name == "streamhost" then
local is_proxy = false
if tag.attr and tag.attr.jid and string.find(tag.attr.jid, "proxy.") ~= nil then
is_proxy = true
elseif tag.attr and tag.attr.host and string.find(tag.attr.host, "proxy.") ~= nil then
is_proxy = true
elseif tag:get_child("proxy") ~= nil then
is_proxy = true
end
if is_proxy == false then
return nil
end
end
return tag
end
stanza:get_child("query", xmlns_bytestream):maptags(sanitize_streamhost)
local num_bytestreams = 0
for child in stanza:get_child("query", xmlns_bytestream):childtags("bytestream") do
num_bytestreams = num_bytestreams + 1
end
local num_tags = #stanza:get_child("query", xmlns_bytestream).tags
if num_bytestreams < num_tags then
local error_stanza = st.error_reply(stanza, "cancel", "service-unavailable")
error_stanza.attr.to = stanza.attr.to
error_stanza.attr.from = stanza.attr.from
core_route_stanza(module_host, error_stanza);
core_route_stanza(module_host, st.error_reply(stanza, "cancel", "service-unavailable")) --"error", nil, "Couldn't connect to any streamhosts"));
return true;
end
-- Sanitize Jingle
-- FIXME: This should use maptags as above
elseif stanza and stanza.tags and stanza.tags[1] then
local child = stanza.tags[1]
if child and child.name == "jingle" and child.attr.xmlns == "urn:xmpp:jingle:1" then
for a, b in ipairs(stanza.tags[1]) do
if b.name == "content" then
for i, v in ipairs(stanza.tags[1].tags[a]) do
if v.name == "transport" then
local remove_ids = {}
for ii, vv in ipairs(v) do
if vv.name == "candidate" and vv.attr.type and vv.attr.type ~= "relay" then
table.insert(remove_ids, ii)
end
end
local ib = #remove_ids
while ib > 0 do
table.remove(stanza.tags[1].tags[a].tags[i], ib)
ib = ib-1
end
end
end
end
end
end
end
return nil; -- Let the original hook go through
end, 9000);
-- Catch offline messages and allow only those that are encrypted
module:hook("message/offline/handle", function(event)
local origin, stanza = event.origin, event.stanza;
if not stanza:child_with_name("body") then
return true; -- Only log actual messages
end
-- Let host and bare messages go through anyway
if not (stanza.attr.from == "" or stanza.attr.from == module_host or stanza.attr.to == module_host) then
if stanza and stanza.name and stanza.name == "message" then
if stanza.tags and stanza.tags[2] then
local is_encrypted = false
if stanza.tags[2].name == "body" then
if string.find(stanza.tags[2]:get_text(), "?OTR:") == 1 then
is_encrypted = true;
end
if string.find(stanza.tags[2]:get_text(), "*** Encrypted with the Gaim-Encryption plugin", 1, true) == 1 then
is_encrypted = true;
end
end
if stanza.tags[2].name == "x" and stanza.tags[2].attr.xmlns == "jabber:x:encrypted" then
is_encrypted = true;
end
if is_encrypted == false then
local error_stanza =
st.message({ to = origin.full_jid, from = stanza.attr.to })
:tag("body"):text("User offline; offline messages are only allowed when you use encryption");
origin.send( error_stanza);
return true; -- Block the message from getting stored
end
end
end
end
return nil; -- let the hook go along
end, 9000); -- Priority over 9000, nothing should be over 9000, really!
module:hook("message/host", function(event)
local origin, stanza = event.origin, event.stanza;
local message_body = stanza:child_with_name("body"):get_text()
if string.find(message_body, "#!help") == 1 then
local help_str = [[#!help This message
#!version Returns mod_auth_anon's current version
#!changelog Returns the full changelog (one message per version)]]
local reply_stanza = st.message({to = origin.full_jid, from = module_host, type = "chat"})
:tag("body"):text(help_str)
origin.send(reply_stanza)
return true
elseif string.find(message_body, "#!version") == 1 then
local current_version = mod_auth_anon_changelog[1][1]
local reply_stanza = st.message({to = origin.full_jid, from = module_host, type = "chat"})
:tag("body"):text("Current version of Mod Auth Anon: " .. current_version )
origin.send(reply_stanza)
return true
elseif string.find(message_body, "#!changelog") == 1 then
local changelog = ""
for i, v in ipairs(mod_auth_anon_changelog) do
local version_body = "Version "..v[1].."\n"..v[2]
local reply_stanza = st.message({to = origin.full_jid, from = module_host, type = "chat"})
:tag("body"):text(version_body)
origin.send(reply_stanza)
end
return true
-- elseif string.find(message_body, "#!hash") == 1 then
-- local file_path = string.sub(debug.getinfo(1).source, 2)
-- module:log("debug", file_path)
-- local file = assert(io.open(file_path, "r"))
-- local x = ""
-- for line in file:lines() do
-- x = x..line
-- end
-- file:close()
--
-- local file_hash = hashes.md5(x, true)
--
-- module:log("debug", file_path .. " " .. file_hash)
--
-- return true
else
return nil
end
end, 9000)
function module.load()
assert (connect() ~= nil)
assert (create_table() ~= nil)
assert (purge_users() ~= nil)
module:hook("roster-load", inject_roster_contacts);
datamanager.add_callback(handle_datamanager);
end
function module.unload()
datamanager.remove_callback(handle_datamanager);
disconnect();
end