Merge timber->trunk - thanks everyone!
[prosody.git] / util / x509.lua
1 -- Prosody IM
2 -- Copyright (C) 2010 Matthew Wild
3 -- Copyright (C) 2010 Paul Aurich
4 --
5 -- This project is MIT/X11 licensed. Please see the
6 -- COPYING file in the source package for more information.
7 --
8
9 -- TODO: I feel a fair amount of this logic should be integrated into Luasec,
10 -- so that everyone isn't re-inventing the wheel.  Dependencies on
11 -- IDN libraries complicate that.
12
13
14 -- [TLS-CERTS] - http://tools.ietf.org/html/rfc6125
15 -- [XMPP-CORE] - http://tools.ietf.org/html/rfc6120
16 -- [SRV-ID]    - http://tools.ietf.org/html/rfc4985
17 -- [IDNA]      - http://tools.ietf.org/html/rfc5890
18 -- [LDAP]      - http://tools.ietf.org/html/rfc4519
19 -- [PKIX]      - http://tools.ietf.org/html/rfc5280
20
21 local nameprep = require "util.encodings".stringprep.nameprep;
22 local idna_to_ascii = require "util.encodings".idna.to_ascii;
23 local log = require "util.logger".init("x509");
24 local pairs, ipairs = pairs, ipairs;
25 local s_format = string.format;
26 local t_insert = table.insert;
27 local t_concat = table.concat;
28
29 module "x509"
30
31 local oid_commonname = "2.5.4.3"; -- [LDAP] 2.3
32 local oid_subjectaltname = "2.5.29.17"; -- [PKIX] 4.2.1.6
33 local oid_xmppaddr = "1.3.6.1.5.5.7.8.5"; -- [XMPP-CORE]
34 local oid_dnssrv   = "1.3.6.1.5.5.7.8.7"; -- [SRV-ID]
35
36 -- Compare a hostname (possibly international) with asserted names
37 -- extracted from a certificate.
38 -- This function follows the rules laid out in
39 -- sections 6.4.1 and 6.4.2 of [TLS-CERTS]
40 --
41 -- A wildcard ("*") all by itself is allowed only as the left-most label
42 local function compare_dnsname(host, asserted_names)
43         -- TODO: Sufficient normalization?  Review relevant specs.
44         local norm_host = idna_to_ascii(host)
45         if norm_host == nil then
46                 log("info", "Host %s failed IDNA ToASCII operation", host)
47                 return false
48         end
49
50         norm_host = norm_host:lower()
51
52         local host_chopped = norm_host:gsub("^[^.]+%.", "") -- everything after the first label
53
54         for i=1,#asserted_names do
55                 local name = asserted_names[i]
56                 if norm_host == name:lower() then
57                         log("debug", "Cert dNSName %s matched hostname", name);
58                         return true
59                 end
60
61                 -- Allow the left most label to be a "*"
62                 if name:match("^%*%.") then
63                         local rest_name = name:gsub("^[^.]+%.", "")
64                         if host_chopped == rest_name:lower() then
65                                 log("debug", "Cert dNSName %s matched hostname", name);
66                                 return true
67                         end
68                 end
69         end
70
71         return false
72 end
73
74 -- Compare an XMPP domain name with the asserted id-on-xmppAddr
75 -- identities extracted from a certificate.  Both are UTF8 strings.
76 --
77 -- Per [XMPP-CORE], matches against asserted identities don't include
78 -- wildcards, so we just do a normalize on both and then a string comparison
79 --
80 -- TODO: Support for full JIDs?
81 local function compare_xmppaddr(host, asserted_names)
82         local norm_host = nameprep(host)
83
84         for i=1,#asserted_names do
85                 local name = asserted_names[i]
86
87                 -- We only want to match against bare domains right now, not
88                 -- those crazy full-er JIDs.
89                 if name:match("[@/]") then
90                         log("debug", "Ignoring xmppAddr %s because it's not a bare domain", name)
91                 else
92                         local norm_name = nameprep(name)
93                         if norm_name == nil then
94                                 log("info", "Ignoring xmppAddr %s, failed nameprep!", name)
95                         else
96                                 if norm_host == norm_name then
97                                         log("debug", "Cert xmppAddr %s matched hostname", name)
98                                         return true
99                                 end
100                         end
101                 end
102         end
103
104         return false
105 end
106
107 -- Compare a host + service against the asserted id-on-dnsSRV (SRV-ID)
108 -- identities extracted from a certificate.
109 --
110 -- Per [SRV-ID], the asserted identities will be encoded in ASCII via ToASCII.
111 -- Comparison is done case-insensitively, and a wildcard ("*") all by itself
112 -- is allowed only as the left-most non-service label.
113 local function compare_srvname(host, service, asserted_names)
114         local norm_host = idna_to_ascii(host)
115         if norm_host == nil then
116                 log("info", "Host %s failed IDNA ToASCII operation", host);
117                 return false
118         end
119
120         -- Service names start with a "_"
121         if service:match("^_") == nil then service = "_"..service end
122
123         norm_host = norm_host:lower();
124         local host_chopped = norm_host:gsub("^[^.]+%.", "") -- everything after the first label
125
126         for i=1,#asserted_names do
127                 local asserted_service, name = asserted_names[i]:match("^(_[^.]+)%.(.*)");
128                 if service == asserted_service then
129                         if norm_host == name:lower() then
130                                 log("debug", "Cert SRVName %s matched hostname", name);
131                                 return true;
132                         end
133
134                         -- Allow the left most label to be a "*"
135                         if name:match("^%*%.") then
136                                 local rest_name = name:gsub("^[^.]+%.", "")
137                                 if host_chopped == rest_name:lower() then
138                                         log("debug", "Cert SRVName %s matched hostname", name)
139                                         return true
140                                 end
141                         end
142                         if norm_host == name:lower() then
143                                 log("debug", "Cert SRVName %s matched hostname", name);
144                                 return true
145                         end
146                 end
147         end
148
149         return false
150 end
151
152 function verify_identity(host, service, cert)
153         local ext = cert:extensions()
154         if ext[oid_subjectaltname] then
155                 local sans = ext[oid_subjectaltname];
156
157                 -- Per [TLS-CERTS] 6.3, 6.4.4, "a client MUST NOT seek a match for a
158                 -- reference identifier if the presented identifiers include a DNS-ID
159                 -- SRV-ID, URI-ID, or any application-specific identifier types"
160                 local had_supported_altnames = false
161
162                 if sans[oid_xmppaddr] then
163                         had_supported_altnames = true
164                         if compare_xmppaddr(host, sans[oid_xmppaddr]) then return true end
165                 end
166
167                 if sans[oid_dnssrv] then
168                         had_supported_altnames = true
169                         -- Only check srvNames if the caller specified a service
170                         if service and compare_srvname(host, service, sans[oid_dnssrv]) then return true end
171                 end
172
173                 if sans["dNSName"] then
174                         had_supported_altnames = true
175                         if compare_dnsname(host, sans["dNSName"]) then return true end
176                 end
177
178                 -- We don't need URIs, but [TLS-CERTS] is clear.
179                 if sans["uniformResourceIdentifier"] then
180                         had_supported_altnames = true
181                 end
182
183                 if had_supported_altnames then return false end
184         end
185
186         -- Extract a common name from the certificate, and check it as if it were
187         -- a dNSName subjectAltName (wildcards may apply for, and receive,
188         -- cat treats)
189         --
190         -- Per [TLS-CERTS] 1.8, a CN-ID is the Common Name from a cert subject
191         -- which has one and only one Common Name
192         local subject = cert:subject()
193         local cn = nil
194         for i=1,#subject do
195                 local dn = subject[i]
196                 if dn["oid"] == oid_commonname then
197                         if cn then
198                                 log("info", "Certificate has multiple common names")
199                                 return false
200                         end
201
202                         cn = dn["value"];
203                 end
204         end
205
206         if cn then
207                 -- Per [TLS-CERTS] 6.4.4, follow the comparison rules for dNSName SANs.
208                 return compare_dnsname(host, { cn })
209         end
210
211         -- If all else fails, well, why should we be any different?
212         return false
213 end
214
215 -- TODO Rename? Split out subroutines?
216 -- Also, this is probably openssl specific, what TODO about that?
217 function genx509san(hosts, config, certhosts, raw) -- recive config through that or some better way?
218         local function utf8string(s)
219                 -- This is how we tell openssl not to encode UTF-8 strings as Latin1
220                 return s_format("FORMAT:UTF8,UTF8:%s", s);
221         end
222
223         local function ia5string(s)
224                 return s_format("IA5STRING:%s", s);
225         end
226
227         local function dnsname(t, host)
228                 t_insert(t.DNS, idna_to_ascii(host));
229         end
230
231         local function srvname(t, host, service)
232                 t_insert(t.otherName, s_format("%s;%s", oid_dnssrv, ia5string("_" .. service .."." .. idna_to_ascii(host))));
233         end
234
235         local function xmppAddr(t, host)
236                 t_insert(t.otherName, s_format("%s;%s", oid_xmppaddr, utf8string(host)));
237         end
238
239         -----------------------------
240
241         local san = {
242                 DNS = {};
243                 otherName = {};
244         };
245
246         local sslsanconf = { };
247
248         for i = 1,#certhosts do
249                 local certhost = certhosts[i];
250                 for name, host in pairs(hosts) do
251                         if name == certhost or name:sub(-1-#certhost) == "."..certhost then
252                                 dnsname(san, name);
253                                 --print(name .. "#component_module: " .. (config.get(name, "core", "component_module") or "nil"));
254                                 if config.get(name, "core", "component_module") == nil then
255                                         srvname(san, name, "xmpp-client");
256                                 end
257                                 --print(name .. "#anonymous_login: " .. tostring(config.get(name, "core", "anonymous_login")));
258                                 if not (config.get(name, "core", "anonymous_login") or
259                                                 config.get(name, "core", "authentication") == "anonymous") then
260                                         srvname(san, name, "xmpp-server");
261                                 end
262                                 xmppAddr(san, name);
263                         end
264                 end
265         end
266
267         for t, n in pairs(san) do
268                 for i = 1,#n do
269                         t_insert(sslsanconf, s_format("%s.%d = %s", t, i -1, n[i]));
270                 end
271         end
272
273         return raw and sslsanconf or t_concat(sslsanconf, "\n");
274 end
275
276 function baseconf()
277         return {
278                 req = {
279                         distinguished_name = "distinguished_name",
280                         req_extensions = "v3_extensions",
281                         x509_extensions = "v3_extensions",
282                         prompt = "no",
283                 },
284                 distinguished_name = {
285                         commonName = "example.com",
286                         countryName = "GB",
287                         localityName = "The Internet",
288                         organizationName = "Your Organisation",
289                         organizationalUnitName = "XMPP Department",
290                         emailAddress = "xmpp@example.com",
291                 },
292                 v3_extensions = {
293                         basicConstraints = "CA:FALSE",
294                         keyUsage = "digitalSignature,keyEncipherment",
295                         extendedKeyUsage = "serverAuth,clientAuth",
296                         subjectAltName = "@subject_alternative_name",
297                 },
298                 subject_alternative_name = { },
299         }
300 end
301
302 function serialize_conf(conf)
303         local s = "";
304         for k, t in pairs(conf) do
305                 s = s .. ("[%s]\n"):format(k);
306                 if t[1] then
307                         for i, v in ipairs(t) do
308                                 s = s .. ("%s\n"):format(v);
309                         end
310                 else
311                         for k, v in pairs(t) do
312                                 s = s .. ("%s = %s\n"):format(k, v);
313                         end
314                 end
315                 s = s .. "\n";
316         end
317         return s;
318 end
319
320 return _M;