cluster-verify checks uniformity of PV sizes
[ganeti-local] / lib / utils / x509.py
1 #
2 #
3
4 # Copyright (C) 2006, 2007, 2010, 2011, 2012 Google Inc.
5 #
6 # This program is free software; you can redistribute it and/or modify
7 # it under the terms of the GNU General Public License as published by
8 # the Free Software Foundation; either version 2 of the License, or
9 # (at your option) any later version.
10 #
11 # This program is distributed in the hope that it will be useful, but
12 # WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
14 # General Public License for more details.
15 #
16 # You should have received a copy of the GNU General Public License
17 # along with this program; if not, write to the Free Software
18 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
19 # 02110-1301, USA.
20
21 """Utility functions for X509.
22
23 """
24
25 import time
26 import OpenSSL
27 import re
28 import datetime
29 import calendar
30 import errno
31 import logging
32
33 from ganeti import errors
34 from ganeti import constants
35 from ganeti import pathutils
36
37 from ganeti.utils import text as utils_text
38 from ganeti.utils import io as utils_io
39 from ganeti.utils import hash as utils_hash
40
41
42 HEX_CHAR_RE = r"[a-zA-Z0-9]"
43 VALID_X509_SIGNATURE_SALT = re.compile("^%s+$" % HEX_CHAR_RE, re.S)
44 X509_SIGNATURE = re.compile(r"^%s:\s*(?P<salt>%s+)/(?P<sign>%s+)$" %
45                             (re.escape(constants.X509_CERT_SIGNATURE_HEADER),
46                              HEX_CHAR_RE, HEX_CHAR_RE),
47                             re.S | re.I)
48
49 # Certificate verification results
50 (CERT_WARNING,
51  CERT_ERROR) = range(1, 3)
52
53 #: ASN1 time regexp
54 _ASN1_TIME_REGEX = re.compile(r"^(\d+)([-+]\d\d)(\d\d)$")
55
56
57 def _ParseAsn1Generalizedtime(value):
58   """Parses an ASN1 GENERALIZEDTIME timestamp as used by pyOpenSSL.
59
60   @type value: string
61   @param value: ASN1 GENERALIZEDTIME timestamp
62   @return: Seconds since the Epoch (1970-01-01 00:00:00 UTC)
63
64   """
65   m = _ASN1_TIME_REGEX.match(value)
66   if m:
67     # We have an offset
68     asn1time = m.group(1)
69     hours = int(m.group(2))
70     minutes = int(m.group(3))
71     utcoffset = (60 * hours) + minutes
72   else:
73     if not value.endswith("Z"):
74       raise ValueError("Missing timezone")
75     asn1time = value[:-1]
76     utcoffset = 0
77
78   parsed = time.strptime(asn1time, "%Y%m%d%H%M%S")
79
80   tt = datetime.datetime(*(parsed[:7])) - datetime.timedelta(minutes=utcoffset)
81
82   return calendar.timegm(tt.utctimetuple())
83
84
85 def GetX509CertValidity(cert):
86   """Returns the validity period of the certificate.
87
88   @type cert: OpenSSL.crypto.X509
89   @param cert: X509 certificate object
90
91   """
92   # The get_notBefore and get_notAfter functions are only supported in
93   # pyOpenSSL 0.7 and above.
94   try:
95     get_notbefore_fn = cert.get_notBefore
96   except AttributeError:
97     not_before = None
98   else:
99     not_before_asn1 = get_notbefore_fn()
100
101     if not_before_asn1 is None:
102       not_before = None
103     else:
104       not_before = _ParseAsn1Generalizedtime(not_before_asn1)
105
106   try:
107     get_notafter_fn = cert.get_notAfter
108   except AttributeError:
109     not_after = None
110   else:
111     not_after_asn1 = get_notafter_fn()
112
113     if not_after_asn1 is None:
114       not_after = None
115     else:
116       not_after = _ParseAsn1Generalizedtime(not_after_asn1)
117
118   return (not_before, not_after)
119
120
121 def _VerifyCertificateInner(expired, not_before, not_after, now,
122                             warn_days, error_days):
123   """Verifies certificate validity.
124
125   @type expired: bool
126   @param expired: Whether pyOpenSSL considers the certificate as expired
127   @type not_before: number or None
128   @param not_before: Unix timestamp before which certificate is not valid
129   @type not_after: number or None
130   @param not_after: Unix timestamp after which certificate is invalid
131   @type now: number
132   @param now: Current time as Unix timestamp
133   @type warn_days: number or None
134   @param warn_days: How many days before expiration a warning should be reported
135   @type error_days: number or None
136   @param error_days: How many days before expiration an error should be reported
137
138   """
139   if expired:
140     msg = "Certificate is expired"
141
142     if not_before is not None and not_after is not None:
143       msg += (" (valid from %s to %s)" %
144               (utils_text.FormatTime(not_before),
145                utils_text.FormatTime(not_after)))
146     elif not_before is not None:
147       msg += " (valid from %s)" % utils_text.FormatTime(not_before)
148     elif not_after is not None:
149       msg += " (valid until %s)" % utils_text.FormatTime(not_after)
150
151     return (CERT_ERROR, msg)
152
153   elif not_before is not None and not_before > now:
154     return (CERT_WARNING,
155             "Certificate not yet valid (valid from %s)" %
156             utils_text.FormatTime(not_before))
157
158   elif not_after is not None:
159     remaining_days = int((not_after - now) / (24 * 3600))
160
161     msg = "Certificate expires in about %d days" % remaining_days
162
163     if error_days is not None and remaining_days <= error_days:
164       return (CERT_ERROR, msg)
165
166     if warn_days is not None and remaining_days <= warn_days:
167       return (CERT_WARNING, msg)
168
169   return (None, None)
170
171
172 def VerifyX509Certificate(cert, warn_days, error_days):
173   """Verifies a certificate for LUClusterVerify.
174
175   @type cert: OpenSSL.crypto.X509
176   @param cert: X509 certificate object
177   @type warn_days: number or None
178   @param warn_days: How many days before expiration a warning should be reported
179   @type error_days: number or None
180   @param error_days: How many days before expiration an error should be reported
181
182   """
183   # Depending on the pyOpenSSL version, this can just return (None, None)
184   (not_before, not_after) = GetX509CertValidity(cert)
185
186   now = time.time() + constants.NODE_MAX_CLOCK_SKEW
187
188   return _VerifyCertificateInner(cert.has_expired(), not_before, not_after,
189                                  now, warn_days, error_days)
190
191
192 def SignX509Certificate(cert, key, salt):
193   """Sign a X509 certificate.
194
195   An RFC822-like signature header is added in front of the certificate.
196
197   @type cert: OpenSSL.crypto.X509
198   @param cert: X509 certificate object
199   @type key: string
200   @param key: Key for HMAC
201   @type salt: string
202   @param salt: Salt for HMAC
203   @rtype: string
204   @return: Serialized and signed certificate in PEM format
205
206   """
207   if not VALID_X509_SIGNATURE_SALT.match(salt):
208     raise errors.GenericError("Invalid salt: %r" % salt)
209
210   # Dumping as PEM here ensures the certificate is in a sane format
211   cert_pem = OpenSSL.crypto.dump_certificate(OpenSSL.crypto.FILETYPE_PEM, cert)
212
213   return ("%s: %s/%s\n\n%s" %
214           (constants.X509_CERT_SIGNATURE_HEADER, salt,
215            utils_hash.Sha1Hmac(key, cert_pem, salt=salt),
216            cert_pem))
217
218
219 def _ExtractX509CertificateSignature(cert_pem):
220   """Helper function to extract signature from X509 certificate.
221
222   """
223   # Extract signature from original PEM data
224   for line in cert_pem.splitlines():
225     if line.startswith("---"):
226       break
227
228     m = X509_SIGNATURE.match(line.strip())
229     if m:
230       return (m.group("salt"), m.group("sign"))
231
232   raise errors.GenericError("X509 certificate signature is missing")
233
234
235 def LoadSignedX509Certificate(cert_pem, key):
236   """Verifies a signed X509 certificate.
237
238   @type cert_pem: string
239   @param cert_pem: Certificate in PEM format and with signature header
240   @type key: string
241   @param key: Key for HMAC
242   @rtype: tuple; (OpenSSL.crypto.X509, string)
243   @return: X509 certificate object and salt
244
245   """
246   (salt, signature) = _ExtractX509CertificateSignature(cert_pem)
247
248   # Load and dump certificate to ensure it's in a sane format
249   (cert, sane_pem) = ExtractX509Certificate(cert_pem)
250
251   if not utils_hash.VerifySha1Hmac(key, sane_pem, signature, salt=salt):
252     raise errors.GenericError("X509 certificate signature is invalid")
253
254   return (cert, salt)
255
256
257 def GenerateSelfSignedX509Cert(common_name, validity):
258   """Generates a self-signed X509 certificate.
259
260   @type common_name: string
261   @param common_name: commonName value
262   @type validity: int
263   @param validity: Validity for certificate in seconds
264   @return: a tuple of strings containing the PEM-encoded private key and
265            certificate
266
267   """
268   # Create private and public key
269   key = OpenSSL.crypto.PKey()
270   key.generate_key(OpenSSL.crypto.TYPE_RSA, constants.RSA_KEY_BITS)
271
272   # Create self-signed certificate
273   cert = OpenSSL.crypto.X509()
274   if common_name:
275     cert.get_subject().CN = common_name
276   cert.set_serial_number(1)
277   cert.gmtime_adj_notBefore(0)
278   cert.gmtime_adj_notAfter(validity)
279   cert.set_issuer(cert.get_subject())
280   cert.set_pubkey(key)
281   cert.sign(key, constants.X509_CERT_SIGN_DIGEST)
282
283   key_pem = OpenSSL.crypto.dump_privatekey(OpenSSL.crypto.FILETYPE_PEM, key)
284   cert_pem = OpenSSL.crypto.dump_certificate(OpenSSL.crypto.FILETYPE_PEM, cert)
285
286   return (key_pem, cert_pem)
287
288
289 def GenerateSelfSignedSslCert(filename, common_name=constants.X509_CERT_CN,
290                               validity=constants.X509_CERT_DEFAULT_VALIDITY):
291   """Legacy function to generate self-signed X509 certificate.
292
293   @type filename: str
294   @param filename: path to write certificate to
295   @type common_name: string
296   @param common_name: commonName value
297   @type validity: int
298   @param validity: validity of certificate in number of days
299   @return: a tuple of strings containing the PEM-encoded private key and
300            certificate
301
302   """
303   # TODO: Investigate using the cluster name instead of X505_CERT_CN for
304   # common_name, as cluster-renames are very seldom, and it'd be nice if RAPI
305   # and node daemon certificates have the proper Subject/Issuer.
306   (key_pem, cert_pem) = GenerateSelfSignedX509Cert(common_name,
307                                                    validity * 24 * 60 * 60)
308
309   utils_io.WriteFile(filename, mode=0400, data=key_pem + cert_pem)
310   return (key_pem, cert_pem)
311
312
313 def ExtractX509Certificate(pem):
314   """Extracts the certificate from a PEM-formatted string.
315
316   @type pem: string
317   @rtype: tuple; (OpenSSL.X509 object, string)
318   @return: Certificate object and PEM-formatted certificate
319
320   """
321   cert = OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_PEM, pem)
322
323   return (cert,
324           OpenSSL.crypto.dump_certificate(OpenSSL.crypto.FILETYPE_PEM, cert))
325
326
327 def PrepareX509CertKeyCheck(cert, key):
328   """Get function for verifying certificate with a certain private key.
329
330   @type key: OpenSSL.crypto.PKey
331   @param key: Private key object
332   @type cert: OpenSSL.crypto.X509
333   @param cert: X509 certificate object
334   @rtype: callable
335   @return: Callable doing the actual check; will raise C{OpenSSL.SSL.Error} if
336     certificate is not signed by given private key
337
338   """
339   ctx = OpenSSL.SSL.Context(OpenSSL.SSL.TLSv1_METHOD)
340   ctx.use_privatekey(key)
341   ctx.use_certificate(cert)
342
343   return ctx.check_privatekey
344
345
346 def CheckNodeCertificate(cert, _noded_cert_file=pathutils.NODED_CERT_FILE):
347   """Checks the local node daemon certificate against given certificate.
348
349   Both certificates must be signed with the same key (as stored in the local
350   L{pathutils.NODED_CERT_FILE} file). No error is raised if no local
351   certificate can be found.
352
353   @type cert: OpenSSL.crypto.X509
354   @param cert: X509 certificate object
355   @raise errors.X509CertError: When an error related to X509 occurred
356   @raise errors.GenericError: When the verification failed
357
358   """
359   try:
360     noded_pem = utils_io.ReadFile(_noded_cert_file)
361   except EnvironmentError, err:
362     if err.errno != errno.ENOENT:
363       raise
364
365     logging.debug("Node certificate file '%s' was not found", _noded_cert_file)
366     return
367
368   try:
369     noded_cert = \
370       OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_PEM, noded_pem)
371   except Exception, err:
372     raise errors.X509CertError(_noded_cert_file,
373                                "Unable to load certificate: %s" % err)
374
375   try:
376     noded_key = \
377       OpenSSL.crypto.load_privatekey(OpenSSL.crypto.FILETYPE_PEM, noded_pem)
378   except Exception, err:
379     raise errors.X509CertError(_noded_cert_file,
380                                "Unable to load private key: %s" % err)
381
382   # Check consistency of server.pem file
383   check_fn = PrepareX509CertKeyCheck(noded_cert, noded_key)
384   try:
385     check_fn()
386   except OpenSSL.SSL.Error:
387     # This should never happen as it would mean the certificate in server.pem
388     # is out of sync with the private key stored in the same file
389     raise errors.X509CertError(_noded_cert_file,
390                                "Certificate does not match with private key")
391
392   # Check with supplied certificate with local key
393   check_fn = PrepareX509CertKeyCheck(cert, noded_key)
394   try:
395     check_fn()
396   except OpenSSL.SSL.Error:
397     raise errors.GenericError("Given cluster certificate does not match"
398                               " local key")