File

plugins/mod_vcard_legacy.lua @ 13801:a5d5fefb8b68 13.0

mod_tls: Enable Prosody's certificate checking for incoming s2s connections (fixes #1916) (thanks Damian, Zash) Various options in Prosody allow control over the behaviour of the certificate verification process For example, some deployments choose to allow falling back to traditional "dialback" authentication (XEP-0220), while others verify via DANE, hard-coded fingerprints, or other custom plugins. Implementing this flexibility requires us to override OpenSSL's default certificate verification, to allow Prosody to verify the certificate itself, apply custom policies and make decisions based on the outcome. To enable our custom logic, we have to suppress OpenSSL's default behaviour of aborting the connection with a TLS alert message. With LuaSec, this can be achieved by using the verifyext "lsec_continue" flag. We also need to use the lsec_ignore_purpose flag, because XMPP s2s uses server certificates as "client" certificates (for mutual TLS verification in outgoing s2s connections). Commit 99d2100d2918 moved these settings out of the defaults and into mod_s2s, because we only really need these changes for s2s, and they should be opt-in, rather than automatically applied to all TLS services we offer. That commit was incomplete, because it only added the flags for incoming direct TLS connections. StartTLS connections are handled by mod_tls, which was not applying the lsec_* flags. It previously worked because they were already in the defaults. This resulted in incoming s2s connections with "invalid" certificates being aborted early by OpenSSL, even if settings such as `s2s_secure_auth = false` or DANE were present in the config. Outgoing s2s connections inherit verify "none" from the defaults, which means OpenSSL will receive the cert but will not terminate the connection when it is deemed invalid. This means we don't need lsec_continue there, and we also don't need lsec_ignore_purpose (because the remote peer is a "server"). Wondering why we can't just use verify "none" for incoming s2s? It's because in that mode, OpenSSL won't request a certificate from the peer for incoming connections. Setting verify "peer" is how you ask OpenSSL to request a certificate from the client, but also what triggers its built-in verification.
author Matthew Wild <mwild1@gmail.com>
date Tue, 01 Apr 2025 17:26:56 +0100
parent 12977:74b9e05af71e
line wrap: on
line source

local st = require "prosody.util.stanza";
local jid_split = require "prosody.util.jid".split;

local mod_pep = module:depends("pep");

local sha1 = require "prosody.util.hashes".sha1;
local base64_decode = require "prosody.util.encodings".base64.decode;

local vcards = module:open_store("vcard");

module:add_feature("vcard-temp");
module:hook("account-disco-info", function (event)
	event.reply:tag("feature", { var = "urn:xmpp:pep-vcard-conversion:0" }):up();
end);

local function handle_error(origin, stanza, err)
	if err == "forbidden" then
		origin.send(st.error_reply(stanza, "auth", "forbidden"));
	elseif err == "internal-server-error" then
		origin.send(st.error_reply(stanza, "wait", "internal-server-error"));
	else
		origin.send(st.error_reply(stanza, "modify", "undefined-condition", err));
	end
end

-- Simple translations
-- <foo><text>hey</text></foo> -> <FOO>hey</FOO>
local simple_map = {
	nickname = "text";
	title = "text";
	role = "text";
	categories = "text";
	note = "text";
	url = "uri";
	bday = "date";
}

module:hook("iq-get/bare/vcard-temp:vCard", function (event)
	local origin, stanza = event.origin, event.stanza;
	local pep_service = mod_pep.get_pep_service(jid_split(stanza.attr.to) or origin.username);
	local ok, _, vcard4_item = pep_service:get_last_item("urn:xmpp:vcard4", stanza.attr.from);

	local vcard_temp = st.stanza("vCard", { xmlns = "vcard-temp" });
	if ok and vcard4_item then
		local vcard4 = vcard4_item.tags[1];

		local fn = vcard4:get_child("fn");
		vcard_temp:text_tag("FN", fn and fn:get_child_text("text"));

		local v4n = vcard4:get_child("n");
		vcard_temp:tag("N")
			:text_tag("FAMILY", v4n and v4n:get_child_text("surname"))
			:text_tag("GIVEN", v4n and v4n:get_child_text("given"))
			:text_tag("MIDDLE", v4n and v4n:get_child_text("additional"))
			:text_tag("PREFIX", v4n and v4n:get_child_text("prefix"))
			:text_tag("SUFFIX", v4n and v4n:get_child_text("suffix"))
			:up();

		for tag in vcard4:childtags() do
			local typ = simple_map[tag.name];
			if typ then
				local text = tag:get_child_text(typ);
				if text then
					vcard_temp:text_tag(tag.name:upper(), text);
				end
			elseif tag.name == "email" then
				local text = tag:get_child_text("text");
				if text then
					vcard_temp:tag("EMAIL")
						:text_tag("USERID", text)
						:tag("INTERNET"):up();
					if tag:find"parameters/type/text#" == "home" then
						vcard_temp:tag("HOME"):up();
					elseif tag:find"parameters/type/text#" == "work" then
						vcard_temp:tag("WORK"):up();
					end
					vcard_temp:up();
				end
			elseif tag.name == "tel" then
				local text = tag:get_child_text("uri");
				if text then
					if text:sub(1, 4) == "tel:" then
						text = text:sub(5)
					end
					vcard_temp:tag("TEL"):text_tag("NUMBER", text);
					if tag:find"parameters/type/text#" == "home" then
						vcard_temp:tag("HOME"):up();
					elseif tag:find"parameters/type/text#" == "work" then
						vcard_temp:tag("WORK"):up();
					end
					vcard_temp:up();
				end
			elseif tag.name == "adr" then
				vcard_temp:tag("ADR")
					:text_tag("POBOX", tag:get_child_text("pobox"))
					:text_tag("EXTADD", tag:get_child_text("ext"))
					:text_tag("STREET", tag:get_child_text("street"))
					:text_tag("LOCALITY", tag:get_child_text("locality"))
					:text_tag("REGION", tag:get_child_text("region"))
					:text_tag("PCODE", tag:get_child_text("code"))
					:text_tag("CTRY", tag:get_child_text("country"));
				if tag:find"parameters/type/text#" == "home" then
					vcard_temp:tag("HOME"):up();
				elseif tag:find"parameters/type/text#" == "work" then
					vcard_temp:tag("WORK"):up();
				end
				vcard_temp:up();
			elseif tag.name == "impp" then
				local uri = tag:get_child_text("uri");
				if uri and uri:sub(1, 5) == "xmpp:" then
					vcard_temp:text_tag("JABBERID", uri:sub(6))
				end
			elseif tag.name == "org" then
				vcard_temp:tag("ORG")
					:text_tag("ORGNAME", tag:get_child_text("text"))
				:up();
			end
		end
	else
		local ok, _, nick_item = pep_service:get_last_item("http://jabber.org/protocol/nick", stanza.attr.from);
		if ok and nick_item then
			local nickname = nick_item:get_child_text("nick", "http://jabber.org/protocol/nick");
			if nickname then
				vcard_temp:text_tag("NICKNAME", nickname);
			end
		end
	end

	local ok, avatar_hash, meta = pep_service:get_last_item("urn:xmpp:avatar:metadata", stanza.attr.from);
	if ok and avatar_hash then

		local info = meta.tags[1]:get_child("info");
		if info then
			vcard_temp:tag("PHOTO");

			if info.attr.type then
				vcard_temp:text_tag("TYPE", info.attr.type);
			end

			if info.attr.url then
				vcard_temp:text_tag("EXTVAL", info.attr.url);
			elseif info.attr.id then
				local data_ok, avatar_data = pep_service:get_items("urn:xmpp:avatar:data", stanza.attr.from, { info.attr.id });
				if data_ok and avatar_data and avatar_data[info.attr.id]  then
					local data = avatar_data[info.attr.id];
					vcard_temp:text_tag("BINVAL", data.tags[1]:get_text());
				end
			end
			vcard_temp:up();
		end
	end

	origin.send(st.reply(stanza):add_child(vcard_temp));
	return true;
end);

local node_defaults = {
	access_model = "open";
	_defaults_only = true;
};

function vcard_to_pep(vcard_temp)
	local avatar = {};

	local vcard4 = st.stanza("item", { xmlns = "http://jabber.org/protocol/pubsub", id = "current" })
		:tag("vcard", { xmlns = 'urn:ietf:params:xml:ns:vcard-4.0' });

	vcard4:tag("fn"):text_tag("text", vcard_temp:get_child_text("FN")):up();

	local N = vcard_temp:get_child("N");

	vcard4:tag("n")
		:text_tag("surname", N and N:get_child_text("FAMILY"))
		:text_tag("given", N and N:get_child_text("GIVEN"))
		:text_tag("additional", N and N:get_child_text("MIDDLE"))
		:text_tag("prefix", N and N:get_child_text("PREFIX"))
		:text_tag("suffix", N and N:get_child_text("SUFFIX"))
	:up();

	for tag in vcard_temp:childtags() do
		local typ = simple_map[tag.name:lower()];
		if typ then
			local text = tag:get_text();
			if text then
				vcard4:tag(tag.name:lower()):text_tag(typ, text):up();
			end
		elseif tag.name == "EMAIL" then
			local text = tag:get_child_text("USERID");
			if text then
				vcard4:tag("email")
				vcard4:text_tag("text", text)
				vcard4:tag("parameters"):tag("type");
				if tag:get_child("HOME") then
					vcard4:text_tag("text", "home");
				elseif tag:get_child("WORK") then
					vcard4:text_tag("text", "work");
				end
				vcard4:up():up():up();
			end
		elseif tag.name == "TEL" then
			local text = tag:get_child_text("NUMBER");
			if text then
				vcard4:tag("tel"):text_tag("uri", "tel:"..text);
			end
			vcard4:tag("parameters"):tag("type");
			if tag:get_child("HOME") then
				vcard4:text_tag("text", "home");
			elseif tag:get_child("WORK") then
				vcard4:text_tag("text", "work");
			end
			vcard4:up():up():up();
		elseif tag.name == "ORG" then
			local text = tag:get_child_text("ORGNAME");
			if text then
				vcard4:tag("org"):text_tag("text", text):up();
			end
		elseif tag.name == "DESC" then
			local text = tag:get_text();
			if text then
				vcard4:tag("note"):text_tag("text", text):up();
			end
			-- <note> gets mapped into <NOTE> in the other direction
		elseif tag.name == "ADR" then
			vcard4:tag("adr")
				:text_tag("pobox", tag:get_child_text("POBOX"))
				:text_tag("ext", tag:get_child_text("EXTADD"))
				:text_tag("street", tag:get_child_text("STREET"))
				:text_tag("locality", tag:get_child_text("LOCALITY"))
				:text_tag("region", tag:get_child_text("REGION"))
				:text_tag("code", tag:get_child_text("PCODE"))
				:text_tag("country", tag:get_child_text("CTRY"));
			vcard4:tag("parameters"):tag("type");
			if tag:get_child("HOME") then
				vcard4:text_tag("text", "home");
			elseif tag:get_child("WORK") then
				vcard4:text_tag("text", "work");
			end
			vcard4:up():up():up();
		elseif tag.name == "JABBERID" then
			vcard4:tag("impp")
				:text_tag("uri", "xmpp:" .. tag:get_text())
			:up();
		elseif tag.name == "PHOTO" then
			local avatar_type = tag:get_child_text("TYPE");
			local avatar_payload = tag:get_child_text("BINVAL");
			-- Can EXTVAL be translated? No way to know the sha1 of the data?

			if avatar_payload then
				local avatar_raw = base64_decode(avatar_payload);
				local avatar_hash = sha1(avatar_raw, true);

				avatar.hash = avatar_hash;

				avatar.meta = st.stanza("item", { id = avatar_hash, xmlns = "http://jabber.org/protocol/pubsub" })
					:tag("metadata", { xmlns="urn:xmpp:avatar:metadata" })
						:tag("info", {
							bytes = tostring(#avatar_raw),
							id = avatar_hash,
							type = avatar_type,
						});

				avatar.data = st.stanza("item", { id = avatar_hash, xmlns = "http://jabber.org/protocol/pubsub" })
					:tag("data", { xmlns="urn:xmpp:avatar:data" })
						:text(avatar_payload);

			end
		end
	end
	return vcard4, avatar;
end

function save_to_pep(pep_service, actor, vcard4, avatar)
	if avatar then

		if pep_service:purge("urn:xmpp:avatar:metadata", actor) then
			pep_service:purge("urn:xmpp:avatar:data", actor);
		end

		if avatar.data and avatar.meta then
			local ok, err = assert(pep_service:publish("urn:xmpp:avatar:data", actor, avatar.hash, avatar.data, node_defaults));
			if ok then
				ok, err = assert(pep_service:publish("urn:xmpp:avatar:metadata", actor, avatar.hash, avatar.meta, node_defaults));
			end
			if not ok then
				return ok, err;
			end
		end
	end

	if vcard4 then
		return pep_service:publish("urn:xmpp:vcard4", actor, "current", vcard4, node_defaults);
	end

	return true;
end

module:hook("iq-set/self/vcard-temp:vCard", function (event)
	local origin, stanza = event.origin, event.stanza;
	local pep_service = mod_pep.get_pep_service(origin.username);

	local vcard_temp = stanza.tags[1];

	local ok, err = save_to_pep(pep_service, origin.full_jid, vcard_to_pep(vcard_temp));
	if ok then
		origin.send(st.reply(stanza));
	else
		handle_error(origin, stanza, err);
	end

	return true;
end);

local function inject_xep153(event)
	local origin, stanza = event.origin, event.stanza;
	local username = origin.username;
	if not username then return end
	if stanza.attr.type then return end
	local pep_service = mod_pep.get_pep_service(username);

	local x_update = stanza:get_child("x", "vcard-temp:x:update");
	if not x_update then
		x_update = st.stanza("x", { xmlns = "vcard-temp:x:update" }):tag("photo");
		stanza:add_direct_child(x_update);
	elseif x_update:get_child("photo") then
		return; -- XEP implies that these should be left alone
	else
		x_update:tag("photo");
	end
	local ok, avatar_hash = pep_service:get_last_item("urn:xmpp:avatar:metadata", true);
	if ok and avatar_hash then
		x_update:text(avatar_hash);
	end
end

module:hook("pre-presence/full", inject_xep153, 1);
module:hook("pre-presence/bare", inject_xep153, 1);
module:hook("pre-presence/host", inject_xep153, 1);

if module:get_option_boolean("upgrade_legacy_vcards", true) then
module:hook("resource-bind", function (event)
	local session = event.session;
	local username = session.username;
	local vcard_temp = vcards:get(username);
	if not vcard_temp then
		session.log("debug", "No legacy vCard to migrate or already migrated");
		return;
	end
	local pep_service = mod_pep.get_pep_service(username);
	vcard_temp = st.deserialize(vcard_temp);
	local vcard4, avatars = vcard_to_pep(vcard_temp);
	if pep_service:get_last_item("urn:xmpp:vcard4", true) then
		vcard4 = nil;
	end
	if pep_service:get_last_item("urn:xmpp:avatar:metadata", true)
	or pep_service:get_last_item("urn:xmpp:avatar:data", true) then
		avatars = nil;
	end
	if not (vcard4 or avatars) then
		session.log("debug", "Already PEP data, not overwriting with migrated data");
		vcards:set(username, nil);
		return;
	end
	local ok, err = save_to_pep(pep_service, true, vcard4, avatars);
	if ok and vcards:set(username, nil) then
		session.log("info", "Migrated vCard-temp to PEP");
	else
		session.log("info", "Failed to migrate vCard-temp to PEP: %s", err or "problem emptying 'vcard' store");
	end
end);
end