Merge 0.8->trunk (uh-oh)

Sun, 19 Dec 2010 02:36:56 +0000

author
Matthew Wild <mwild1@gmail.com>
date
Sun, 19 Dec 2010 02:36:56 +0000
changeset 3899
eff0c5fe9119
parent 3780
791aede977da (diff)
parent 3898
b1140808598b (current diff)
child 3901
4447c651e6e6

Merge 0.8->trunk (uh-oh)

fallbacks/lxp.lua file | annotate | diff | comparison | revisions
plugins/mod_admin_telnet.lua file | annotate | diff | comparison | revisions
--- a/core/certmanager.lua	Sat Dec 18 23:15:58 2010 +0000
+++ b/core/certmanager.lua	Sun Dec 19 02:36:56 2010 +0000
@@ -22,6 +22,8 @@
 -- Global SSL options if not overridden per-host
 local default_ssl_config = configmanager.get("*", "core", "ssl");
 local default_capath = "/etc/ssl/certs";
+local default_verify = (ssl and ssl.x509 and { "peer", "client_once", "continue", "ignore_purpose" }) or "none";
+local default_options = { "no_sslv2" };
 
 function create_context(host, mode, user_ssl_config)
 	user_ssl_config = user_ssl_config or default_ssl_config;
@@ -37,8 +39,8 @@
 		certificate = resolve_path(config_path, user_ssl_config.certificate);
 		capath = resolve_path(config_path, user_ssl_config.capath or default_capath);
 		cafile = resolve_path(config_path, user_ssl_config.cafile);
-		verify = user_ssl_config.verify or "none";
-		options = user_ssl_config.options or "no_sslv2";
+		verify = user_ssl_config.verify or default_verify;
+		options = user_ssl_config.options or default_options;
 		ciphers = user_ssl_config.ciphers;
 		depth = user_ssl_config.depth;
 	};
--- a/core/s2smanager.lua	Sat Dec 18 23:15:58 2010 +0000
+++ b/core/s2smanager.lua	Sun Dec 19 02:36:56 2010 +0000
@@ -27,6 +27,7 @@
 local st = require "stanza";
 local stanza = st.stanza;
 local nameprep = require "util.encodings".stringprep.nameprep;
+local cert_verify_identity = require "util.x509".verify_identity;
 
 local fire_event = prosody.events.fire_event;
 local uuid_gen = require "util.uuid".generate;
@@ -373,16 +374,47 @@
 		from=from, to=to, version='1.0', ["xml:lang"]='en'}):top_tag());
 end
 
+local function check_cert_status(session)
+	local conn = session.conn:socket()
+	local cert
+	if conn.getpeercertificate then
+		cert = conn:getpeercertificate()
+	end
+
+	if cert then
+		local chain_valid, err = conn:getpeerchainvalid()
+		if not chain_valid then
+			session.cert_chain_status = "invalid";
+			(session.log or log)("debug", "certificate chain validation result: %s", err);
+		else
+			session.cert_chain_status = "valid";
+
+			local host = session.direction == "incoming" and session.from_host or session.to_host
+
+			-- We'll go ahead and verify the asserted identity if the
+			-- connecting server specified one.
+			if host then
+				if cert_verify_identity(host, "xmpp-server", cert) then
+					session.cert_identity_status = "valid"
+				else
+					session.cert_identity_status = "invalid"
+				end
+			end
+		end
+	end
+end
+
 function streamopened(session, attr)
 	local send = session.sends2s;
 	
 	-- TODO: #29: SASL/TLS on s2s streams
 	session.version = tonumber(attr.version) or 0;
 	
+	-- TODO: Rename session.secure to session.encrypted
 	if session.secure == false then
 		session.secure = true;
 	end
-	
+
 	if session.direction == "incoming" then
 		-- Send a reply stream header
 		session.to_host = attr.to and nameprep(attr.to);
@@ -407,6 +439,9 @@
 				return;
 			end
 		end
+
+		if session.secure and not session.cert_chain_status then check_cert_status(session); end
+
 		send("<?xml version='1.0'?>");
 		send(stanza("stream:stream", { xmlns='jabber:server', ["xmlns:db"]='jabber:server:dialback',
 				["xmlns:stream"]='http://etherx.jabber.org/streams', id=session.streamid, from=session.to_host, to=session.from_host, version=(session.version > 0 and "1.0" or nil) }):top_tag());
@@ -426,7 +461,9 @@
 		-- If we are just using the connection for verifying dialback keys, we won't try and auth it
 		if not attr.id then error("stream response did not give us a streamid!!!"); end
 		session.streamid = attr.id;
-	
+
+		if session.secure and not session.cert_chain_status then check_cert_status(session); end
+
 		-- Send unauthed buffer
 		-- (stanzas which are fine to send before dialback)
 		-- Note that this is *not* the stanza queue (which
--- a/plugins/mod_admin_telnet.lua	Sat Dec 18 23:15:58 2010 +0000
+++ b/plugins/mod_admin_telnet.lua	Sun Dec 19 02:36:56 2010 +0000
@@ -19,6 +19,7 @@
 require "util.iterators";
 local jid_bare = require "util.jid".bare;
 local set, array = require "util.set", require "util.array";
+local cert_verify_identity = require "util.x509".verify_identity;
 
 local commands = {};
 local def_env = {};
@@ -498,7 +499,7 @@
 		for remotehost, session in pairs(host_session.s2sout) do
 			if (not match_jid) or remotehost:match(match_jid) or host:match(match_jid) then
 				count_out = count_out + 1;
-				print("    "..host.." -> "..remotehost..(session.secure and " (encrypted)" or "")..(session.compressed and " (compressed)" or ""));
+				print("    "..host.." -> "..remotehost..(session.cert_identity_status == "valid" and " (secure)" or "")..(session.secure and " (encrypted)" or "")..(session.compressed and " (compressed)" or ""));
 				if session.sendq then
 					print("        There are "..#session.sendq.." queued outgoing stanzas for this connection");
 				end
@@ -535,7 +536,7 @@
 				-- Pft! is what I say to list comprehensions
 				or (session.hosts and #array.collect(keys(session.hosts)):filter(subhost_filter)>0)) then
 				count_in = count_in + 1;
-				print("    "..host.." <- "..(session.from_host or "(unknown)")..(session.secure and " (encrypted)" or "")..(session.compressed and " (compressed)" or ""));
+				print("    "..host.." <- "..(session.from_host or "(unknown)")..(session.cert_identity_status == "valid" and " (secure)" or "")..(session.secure and " (encrypted)" or "")..(session.compressed and " (compressed)" or ""));
 				if session.type == "s2sin_unauthed" then
 						print("        Connection not yet authenticated");
 				end
@@ -561,6 +562,109 @@
 	return true, "Total: "..count_out.." outgoing, "..count_in.." incoming connections";
 end
 
+local function print_subject(print, subject)
+	for _, entry in ipairs(subject) do
+		print(
+			("    %s: %q"):format(
+				entry.name or entry.oid,
+				entry.value:gsub("[\r\n%z%c]", " ")
+			)
+		);
+	end
+end
+
+function def_env.s2s:showcert(domain)
+	local ser = require "util.serialization".serialize;
+	local print = self.session.print;
+	local domain_sessions = set.new(array.collect(keys(incoming_s2s)))
+		/function(session) return session.from_host == domain; end;
+	for local_host in values(prosody.hosts) do
+		local s2sout = local_host.s2sout;
+		if s2sout and s2sout[domain] then
+			domain_sessions:add(s2sout[domain]);
+		end
+	end
+	local cert_set = {};
+	for session in domain_sessions do
+		local conn = session.conn;
+		conn = conn and conn:socket();
+		if not conn.getpeercertificate then
+			if conn.dohandshake then
+				error("This version of LuaSec does not support certificate viewing");
+			end
+		else
+			local cert = conn:getpeercertificate();
+			if cert then
+				local digest = cert:digest("sha1");
+				if not cert_set[digest] then
+					local chain_valid, chain_err = conn:getpeerchainvalid();
+					cert_set[digest] = {
+						{
+						  from = session.from_host,
+						  to = session.to_host,
+						  direction = session.direction
+						};
+						chain_valid = chain_valid;
+						chain_err = chain_err;
+						cert = cert;
+					};
+				else
+					table.insert(cert_set[digest], {
+						from = session.from_host,
+						to = session.to_host,
+						direction = session.direction
+					});
+				end
+			end
+		end
+	end
+	local domain_certs = array.collect(values(cert_set));
+	-- Phew. We now have a array of unique certificates presented by domain.
+	local print = self.session.print;
+	local n_certs = #domain_certs;
+	
+	if n_certs == 0 then
+		return "No certificates found for "..domain;
+	end
+	
+	local function _capitalize_and_colon(byte)
+		return string.upper(byte)..":";
+	end
+	local function pretty_fingerprint(hash)
+		return hash:gsub("..", _capitalize_and_colon):sub(1, -2);
+	end
+	
+	for cert_info in values(domain_certs) do
+		local cert = cert_info.cert;
+		print("---")
+		print("Fingerprint (SHA1): "..pretty_fingerprint(cert:digest("sha1")));
+		print("");
+		local n_streams = #cert_info;
+		print("Currently used on "..n_streams.." stream"..(n_streams==1 and "" or "s")..":");
+		for _, stream in ipairs(cert_info) do
+			if stream.direction == "incoming" then
+				print("    "..stream.to.." <- "..stream.from);
+			else
+				print("    "..stream.from.." -> "..stream.to);
+			end
+		end
+		print("");
+		local chain_valid, err = cert_info.chain_valid, cert_info.chain_err;
+		local valid_identity = cert_verify_identity(domain, "xmpp-server", cert);
+		print("Trusted certificate: "..(chain_valid and "Yes" or ("No ("..err..")")));
+		print("Issuer: ");
+		print_subject(print, cert:issuer());
+		print("");
+		print("Valid for "..domain..": "..(valid_identity and "Yes" or "No"));
+		print("Subject:");
+		print_subject(print, cert:subject());
+	end
+	print("---");
+	return ("Showing "..n_certs.." certificate"
+		..(n_certs==1 and "" or "s")
+		.." presented by "..domain..".");
+end
+
 function def_env.s2s:close(from, to)
 	local print, count = self.session.print, 0;
 	
--- a/plugins/mod_dialback.lua	Sat Dec 18 23:15:58 2010 +0000
+++ b/plugins/mod_dialback.lua	Sun Dec 19 02:36:56 2010 +0000
@@ -132,9 +132,19 @@
 	end
 end);
 
+module:hook_stanza("urn:ietf:params:xml:ns:xmpp-sasl", "failure", function (origin, stanza)
+	if origin.external_auth == "failed" then
+		module:log("debug", "SASL EXTERNAL failed, falling back to dialback");
+		s2s_initiate_dialback(origin);
+		return true;
+	end
+end, 100);
+
 module:hook_stanza(xmlns_stream, "features", function (origin, stanza)
-	s2s_initiate_dialback(origin);
-	return true;
+	if not origin.external_auth or origin.external_auth == "failed" then
+		s2s_initiate_dialback(origin);
+		return true;
+	end
 end, 100);
 
 -- Offer dialback to incoming hosts
--- a/plugins/mod_saslauth.lua	Sat Dec 18 23:15:58 2010 +0000
+++ b/plugins/mod_saslauth.lua	Sun Dec 19 02:36:56 2010 +0000
@@ -11,8 +11,11 @@
 local st = require "util.stanza";
 local sm_bind_resource = require "core.sessionmanager".bind_resource;
 local sm_make_authenticated = require "core.sessionmanager".make_authenticated;
+local s2s_make_authenticated = require "core.s2smanager".make_authenticated;
 local base64 = require "util.encodings".base64;
 
+local cert_verify_identity = require "util.x509".verify_identity;
+
 local nodeprep = require "util.encodings".stringprep.nodeprep;
 local usermanager_get_sasl_handler = require "core.usermanager".get_sasl_handler;
 local t_concat, t_insert = table.concat, table.insert;
@@ -91,8 +94,123 @@
 	return true;
 end
 
+module:hook_stanza(xmlns_sasl, "success", function (session, stanza)
+	if session.type ~= "s2sout_unauthed" or session.external_auth ~= "attempting" then return; end
+	module:log("debug", "SASL EXTERNAL with %s succeeded", session.to_host);
+	session.external_auth = "succeeded"
+	session:reset_stream();
+
+	local default_stream_attr = {xmlns = "jabber:server", ["xmlns:stream"] = "http://etherx.jabber.org/streams",
+	                            ["xmlns:db"] = 'jabber:server:dialback', version = "1.0", to = session.to_host, from = session.from_host};
+	session.sends2s("<?xml version='1.0'?>");
+	session.sends2s(st.stanza("stream:stream", default_stream_attr):top_tag());
+
+	s2s_make_authenticated(session, session.to_host);
+	return true;
+end)
+
+module:hook_stanza(xmlns_sasl, "failure", function (session, stanza)
+	if session.type ~= "s2sout_unauthed" or session.external_auth ~= "attempting" then return; end
+
+	module:log("info", "SASL EXTERNAL with %s failed", session.to_host)
+	-- TODO: Log the failure reason
+	session.external_auth = "failed"
+end, 500)
+
+module:hook_stanza(xmlns_sasl, "failure", function (session, stanza)
+	-- TODO: Dialback wasn't loaded.  Do something useful.
+end, 90)
+
+module:hook_stanza("http://etherx.jabber.org/streams", "features", function (session, stanza)
+	if session.type ~= "s2sout_unauthed" or not session.secure then return; end
+
+	local mechanisms = stanza:get_child("mechanisms", xmlns_sasl)
+	if mechanisms then
+		for mech in mechanisms:childtags() do
+			if mech[1] == "EXTERNAL" then
+				module:log("debug", "Initiating SASL EXTERNAL with %s", session.to_host);
+				local reply = st.stanza("auth", {xmlns = xmlns_sasl, mechanism = "EXTERNAL"});
+				reply:text(base64.encode(session.from_host))
+				session.sends2s(reply)
+				session.external_auth = "attempting"
+				return true
+			end
+		end
+	end
+end, 150);
+
+local function s2s_external_auth(session, stanza)
+	local mechanism = stanza.attr.mechanism;
+
+	if not session.secure then
+		if mechanism == "EXTERNAL" then
+			session.sends2s(build_reply("failure", "encryption-required"))
+		else
+			session.sends2s(build_reply("failure", "invalid-mechanism"))
+		end
+		return true;
+	end
+
+	if mechanism ~= "EXTERNAL" or session.cert_chain_status ~= "valid" then
+		session.sends2s(build_reply("failure", "invalid-mechanism"))
+		return true;
+	end
+
+	local text = stanza[1]
+	if not text then
+		session.sends2s(build_reply("failure", "malformed-request"))
+		return true
+	end
+
+	-- Either the value is "=" and we've already verified the external
+	-- cert identity, or the value is a string and either matches the
+	-- from_host (
+
+	text = base64.decode(text)
+	if not text then
+		session.sends2s(build_reply("failure", "incorrect-encoding"))
+		return true;
+	end
+
+	if session.cert_identity_status == "valid" then
+		if text ~= "" and text ~= session.from_host then
+			session.sends2s(build_reply("failure", "invalid-authzid"))
+			return true
+		end
+	else
+		if text == "" then
+			session.sends2s(build_reply("failure", "invalid-authzid"))
+			return true
+		end
+
+		local cert = session.conn:socket():getpeercertificate()
+		if (cert_verify_identity(text, "xmpp-server", cert)) then
+			session.cert_identity_status = "valid"
+		else
+			session.cert_identity_status = "invalid"
+			session.sends2s(build_reply("failure", "invalid-authzid"))
+			return true
+		end
+	end
+
+	session.external_auth = "succeeded"
+
+	if not session.from_host then
+		session.from_host = text;
+	end
+	session.sends2s(build_reply("success"))
+	module:log("info", "Accepting SASL EXTERNAL identity from %s", text or session.from_host);
+	s2s_make_authenticated(session, text or session.from_host)
+	session:reset_stream();
+	return true
+end
+
 module:hook("stanza/urn:ietf:params:xml:ns:xmpp-sasl:auth", function(event)
 	local session, stanza = event.origin, event.stanza;
+	if session.type == "s2sin_unauthed" then
+		return s2s_external_auth(session, stanza)
+	end
+
 	if session.type ~= "c2s_unauthed" then return; end
 
 	if session.sasl_handler and session.sasl_handler.selected then
@@ -168,6 +286,20 @@
 	end
 end);
 
+module:hook("s2s-stream-features", function(event)
+	local origin, features = event.origin, event.features;
+	if origin.secure and origin.type == "s2sin_unauthed" then
+		-- Offer EXTERNAL if chain is valid and either we didn't validate
+		-- the identity or it passed.
+		if origin.cert_chain_status == "valid" and origin.cert_identity_status ~= "invalid" then --TODO: Configurable
+			module:log("debug", "Offering SASL EXTERNAL")
+			features:tag("mechanisms", { xmlns = xmlns_sasl })
+				:tag("mechanism"):text("EXTERNAL")
+			:up():up();
+		end
+	end
+end);
+
 module:hook("iq/self/urn:ietf:params:xml:ns:xmpp-bind:bind", function(event)
 	local origin, stanza = event.origin, event.stanza;
 	local resource;
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/util/x509.lua	Sun Dec 19 02:36:56 2010 +0000
@@ -0,0 +1,211 @@
+-- Prosody IM
+-- Copyright (C) 2010 Matthew Wild
+-- Copyright (C) 2010 Paul Aurich
+--
+-- This project is MIT/X11 licensed. Please see the
+-- COPYING file in the source package for more information.
+--
+
+-- TODO: I feel a fair amount of this logic should be integrated into Luasec,
+-- so that everyone isn't re-inventing the wheel.  Dependencies on
+-- IDN libraries complicate that.
+
+
+-- [TLS-CERTS] - http://tools.ietf.org/html/draft-saintandre-tls-server-id-check-10
+-- [XMPP-CORE] - http://tools.ietf.org/html/draft-ietf-xmpp-3920bis-18
+-- [SRV-ID]    - http://tools.ietf.org/html/rfc4985
+-- [IDNA]      - http://tools.ietf.org/html/rfc5890
+-- [LDAP]      - http://tools.ietf.org/html/rfc4519
+-- [PKIX]      - http://tools.ietf.org/html/rfc5280
+
+local nameprep = require "util.encodings".stringprep.nameprep;
+local idna_to_ascii = require "util.encodings".idna.to_ascii;
+local log = require "util.logger".init("x509");
+
+module "x509"
+
+local oid_commonname = "2.5.4.3"; -- [LDAP] 2.3
+local oid_subjectaltname = "2.5.29.17"; -- [PKIX] 4.2.1.6
+local oid_xmppaddr = "1.3.6.1.5.5.7.8.5"; -- [XMPP-CORE]
+local oid_dnssrv   = "1.3.6.1.5.5.7.8.7"; -- [SRV-ID]
+
+-- Compare a hostname (possibly international) with asserted names
+-- extracted from a certificate.
+-- This function follows the rules laid out in
+-- sections 4.4.1 and 4.4.2 of [TLS-CERTS]
+--
+-- A wildcard ("*") all by itself is allowed only as the left-most label
+local function compare_dnsname(host, asserted_names)
+	-- TODO: Sufficient normalization?  Review relevant specs.
+	local norm_host = idna_to_ascii(host)
+	if norm_host == nil then
+		log("info", "Host %s failed IDNA ToASCII operation", host)
+		return false
+	end
+
+	norm_host = norm_host:lower()
+
+	local host_chopped = norm_host:gsub("^[^.]+%.", "") -- everything after the first label
+
+	for i=1,#asserted_names do
+		local name = asserted_names[i]
+		if norm_host == name:lower() then
+			log("debug", "Cert dNSName %s matched hostname", name);
+			return true
+		end
+
+		-- Allow the left most label to be a "*"
+		if name:match("^%*%.") then
+			local rest_name = name:gsub("^[^.]+%.", "")
+			if host_chopped == rest_name:lower() then
+				log("debug", "Cert dNSName %s matched hostname", name);
+				return true
+			end
+		end
+	end
+
+	return false
+end
+
+-- Compare an XMPP domain name with the asserted id-on-xmppAddr
+-- identities extracted from a certificate.  Both are UTF8 strings.
+--
+-- Per [XMPP-CORE], matches against asserted identities don't include
+-- wildcards, so we just do a normalize on both and then a string comparison
+--
+-- TODO: Support for full JIDs?
+local function compare_xmppaddr(host, asserted_names)
+	local norm_host = nameprep(host)
+
+	for i=1,#asserted_names do
+		local name = asserted_names[i]
+
+		-- We only want to match against bare domains right now, not
+		-- those crazy full-er JIDs.
+		if name:match("[@/]") then
+			log("debug", "Ignoring xmppAddr %s because it's not a bare domain", name)
+		else
+			local norm_name = nameprep(name)
+			if norm_name == nil then
+				log("info", "Ignoring xmppAddr %s, failed nameprep!", name)
+			else
+				if norm_host == norm_name then
+					log("debug", "Cert xmppAddr %s matched hostname", name)
+					return true
+				end
+			end
+		end
+	end
+
+	return false
+end
+
+-- Compare a host + service against the asserted id-on-dnsSRV (SRV-ID)
+-- identities extracted from a certificate.
+--
+-- Per [SRV-ID], the asserted identities will be encoded in ASCII via ToASCII.
+-- Comparison is done case-insensitively, and a wildcard ("*") all by itself
+-- is allowed only as the left-most non-service label.
+local function compare_srvname(host, service, asserted_names)
+	local norm_host = idna_to_ascii(host)
+	if norm_host == nil then
+		log("info", "Host %s failed IDNA ToASCII operation", host);
+		return false
+	end
+
+	-- Service names start with a "_"
+	if service:match("^_") == nil then service = "_"..service end
+
+	norm_host = norm_host:lower();
+	local host_chopped = norm_host:gsub("^[^.]+%.", "") -- everything after the first label
+
+	for i=1,#asserted_names do
+		local asserted_service, name = asserted_names[i]:match("^(_[^.]+)%.(.*)");
+		if service == asserted_service then
+			if norm_host == name:lower() then
+				log("debug", "Cert SRVName %s matched hostname", name);
+				return true;
+			end
+
+			-- Allow the left most label to be a "*"
+			if name:match("^%*%.") then
+				local rest_name = name:gsub("^[^.]+%.", "")
+				if host_chopped == rest_name:lower() then
+					log("debug", "Cert SRVName %s matched hostname", name)
+					return true
+				end
+			end
+			if norm_host == name:lower() then
+				log("debug", "Cert SRVName %s matched hostname", name);
+				return true
+			end
+		end
+	end
+
+	return false
+end
+
+function verify_identity(host, service, cert)
+	local ext = cert:extensions()
+	if ext[oid_subjectaltname] then
+		local sans = ext[oid_subjectaltname];
+
+		-- Per [TLS-CERTS] 4.3, 4.4.4, "a client MUST NOT seek a match for a
+		-- reference identifier if the presented identifiers include a DNS-ID
+		-- SRV-ID, URI-ID, or any application-specific identifier types"
+		local had_supported_altnames = false
+
+		if sans[oid_xmppaddr] then
+			had_supported_altnames = true
+			if compare_xmppaddr(host, sans[oid_xmppaddr]) then return true end
+		end
+
+		if sans[oid_dnssrv] then
+			had_supported_altnames = true
+			-- Only check srvNames if the caller specified a service
+			if service and compare_srvname(host, service, sans[oid_dnssrv]) then return true end
+		end
+
+		if sans["dNSName"] then
+			had_supported_altnames = true
+			if compare_dnsname(host, sans["dNSName"]) then return true end
+		end
+
+		-- We don't need URIs, but [TLS-CERTS] is clear.
+		if sans["uniformResourceIdentifier"] then
+			had_supported_altnames = true
+		end
+
+		if had_supported_altnames then return false end
+	end
+
+	-- Extract a common name from the certificate, and check it as if it were
+	-- a dNSName subjectAltName (wildcards may apply for, and receive,
+	-- cat treats)
+	--
+	-- Per [TLS-CERTS] 1.5, a CN-ID is the Common Name from a cert subject
+	-- which has one and only one Common Name
+	local subject = cert:subject()
+	local cn = nil
+	for i=1,#subject do
+		local dn = subject[i]
+		if dn["oid"] == oid_commonname then
+			if cn then
+				log("info", "Certificate has multiple common names")
+				return false
+			end
+
+			cn = dn["value"];
+		end
+	end
+
+	if cn then
+		-- Per [TLS-CERTS] 4.4.4, follow the comparison rules for dNSName SANs.
+		return compare_dnsname(host, { cn })
+	end
+
+	-- If all else fails, well, why should we be any different?
+	return false
+end
+
+return _M;

mercurial