client.py 10.5 KB
Newer Older
Vincent Pelletier's avatar
Vincent Pelletier committed
1
# This file is part of caucase
Vincent Pelletier's avatar
Vincent Pelletier committed
2
# Copyright (C) 2017-2018  Nexedi
Vincent Pelletier's avatar
Vincent Pelletier committed
3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
#     Alain Takoudjou <alain.takoudjou@nexedi.com>
#     Vincent Pelletier <vincent@nexedi.com>
#
# caucase is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# caucase is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with caucase.  If not, see <http://www.gnu.org/licenses/>.
"""
Caucase - Certificate Authority for Users, Certificate Authority for SErvices
"""
from __future__ import absolute_import
import datetime
import httplib
import json
import os
import ssl
from urlparse import urlparse
from cryptography import x509
from cryptography.hazmat.backends import default_backend
import cryptography.exceptions
from . import utils
32
from . import version
Vincent Pelletier's avatar
Vincent Pelletier committed
33 34 35 36 37 38 39 40 41 42 43 44 45 46 47

__all__ = (
  'CaucaseError',
  'CaucaseClient',
  'HTTPSOnlyCaucaseClient',
)

_cryptography_backend = default_backend()

class CaucaseError(Exception):
  """
  Raised when server responds with an HTTP error status.
  """
  pass

48
class CaucaseClient(object):
Vincent Pelletier's avatar
Vincent Pelletier committed
49
  """
50
  Caucase HTTP(S) client.
Vincent Pelletier's avatar
Vincent Pelletier committed
51

52
  Expose caucase REST API as pythonic methods.
Vincent Pelletier's avatar
Vincent Pelletier committed
53
  """
54 55
  HTTPConnection = httplib.HTTPConnection
  HTTPSConnection = httplib.HTTPSConnection
Vincent Pelletier's avatar
Vincent Pelletier committed
56

57 58 59 60
  @classmethod
  def updateCAFile(cls, url, ca_crt_path):
    """
    Bootstrap anf maintain a CA file up-to-date.
Vincent Pelletier's avatar
Vincent Pelletier committed
61

62 63 64 65
    url (str)
      URL to caucase, ending in eithr /cas or /cau.
    ca_crt_path (str)
      Path to the CA certificate file, which may not exist.
Vincent Pelletier's avatar
Vincent Pelletier committed
66

67 68 69 70 71
    Return whether an update happened (including whether an already-known
    certificate expired and was discarded).
    """
    if not os.path.exists(ca_crt_path):
      ca_pem = cls(ca_url=url).getCACertificate()
72
      with open(ca_crt_path, 'wb') as ca_crt_file:
73 74 75 76 77 78 79 80 81 82 83 84 85 86 87
        ca_crt_file.write(ca_pem)
      updated = True
    else:
      updated = False
    now = datetime.datetime.utcnow()
    loaded_ca_pem_list = utils.getCertList(ca_crt_path)
    ca_pem_list = [
      x
      for x in loaded_ca_pem_list
      if utils.load_ca_certificate(x).not_valid_after > now
    ]
    ca_pem_list.extend(
      cls(ca_url=url, ca_crt_pem_list=ca_pem_list).getCACertificateChain(),
    )
    if ca_pem_list != loaded_ca_pem_list:
88 89
      data = b''.join(ca_pem_list)
      with open(ca_crt_path, 'wb') as ca_crt_file:
90 91 92
        ca_crt_file.write(data)
      updated = True
    return updated
Vincent Pelletier's avatar
Vincent Pelletier committed
93

94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109
  @classmethod
  def updateCRLFile(cls, url, crl_path, ca_list):
    """
    Bootstrap anf maintain a CRL file up-to-date.

    url (str)
      URL to caucase, ending in eithr /cas or /cau.
    crl_path (str)
      Path to the CRL file, which may not exist.
    ca_list (list of cryptography.x509.Certificate instances)
      One of these CA certificates must have signed the CRL for it to be
      accepted.

    Return whether an update happened.
    """
    if os.path.exists(crl_path):
110
      my_crl = utils.load_crl(open(crl_path, 'rb').read(), ca_list)
111 112 113 114 115
    else:
      my_crl = None
    latest_crl_pem = cls(ca_url=url).getCertificateRevocationList()
    latest_crl = utils.load_crl(latest_crl_pem, ca_list)
    if my_crl is None or latest_crl.signature != my_crl.signature:
116
      with open(crl_path, 'wb') as crl_file:
117 118 119
        crl_file.write(latest_crl_pem)
      return True
    return False
Vincent Pelletier's avatar
Vincent Pelletier committed
120

121 122 123 124 125 126 127
  def __init__(
    self,
    ca_url,
    ca_crt_pem_list=None,
    user_key=None,
    http_ca_crt_pem_list=None,
  ):
Vincent Pelletier's avatar
Vincent Pelletier committed
128 129 130
    # XXX: set timeout to HTTP connections ?
    http_url = urlparse(ca_url)
    port = http_url.port or 80
131
    self._http_connection = self.HTTPConnection(
Vincent Pelletier's avatar
Vincent Pelletier committed
132 133 134 135 136 137
      http_url.hostname,
      port,
      #timeout=,
    )
    self._ca_crt_pem_list = ca_crt_pem_list
    self._path = http_url.path
138 139 140
    ssl_context = ssl.create_default_context(
      # unicode object needed as we use PEM, otherwise create_default_context
      # expects DER.
141 142 143 144 145
      cadata=(
        utils.toUnicode(''.join(http_ca_crt_pem_list))
        if http_ca_crt_pem_list
        else None
      ),
146 147 148 149 150 151 152 153 154 155 156 157 158 159 160
    )
    if not http_ca_crt_pem_list:
      ssl_context.check_hostname = False
      ssl_context.verify_mode = ssl.CERT_NONE
    if user_key:
      try:
        ssl_context.load_cert_chain(user_key)
      except ssl.SSLError as exc:
        raise ValueError('Failed to load user key: %r' % (exc, ))
    self._https_connection = self.HTTPSConnection(
      http_url.hostname,
      443 if port == 80 else port + 1,
      #timeout=,
      context=ssl_context,
    )
Vincent Pelletier's avatar
Vincent Pelletier committed
161 162 163 164

  def _request(self, connection, method, url, body=None, headers=None):
    path = self._path + url
    headers = headers or {}
165
    headers.setdefault('User-Agent', 'caucase ' + version.__version__)
Vincent Pelletier's avatar
Vincent Pelletier committed
166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181
    connection.request(method, path, body, headers)
    response = connection.getresponse()
    response_body = response.read()
    if response.status >= 400:
      raise CaucaseError(response.status, response.getheaders(), response_body)
    assert response.status < 300 # caucase does not redirect
    if response.status == 201:
      return response.getheader('Location')
    return response_body

  def _http(self, method, url, body=None, headers=None):
    return self._request(self._http_connection, method, url, body, headers)

  def _https(self, method, url, body=None, headers=None):
    return self._request(self._https_connection, method, url, body, headers)

182
  def getCertificateRevocationList(self):
Vincent Pelletier's avatar
Vincent Pelletier committed
183 184 185 186 187
    """
    [ANONYMOUS] Retrieve latest CRL.
    """
    return self._http('GET', '/crl')

188
  def getCertificateSigningRequest(self, csr_id):
Vincent Pelletier's avatar
Vincent Pelletier committed
189 190 191 192 193
    """
    [ANONYMOUS] Retrieve an CSR by its identifier.
    """
    return self._http('GET', '/csr/%i' % (csr_id, ))

194
  def getPendingCertificateRequestList(self):
Vincent Pelletier's avatar
Vincent Pelletier committed
195 196 197
    """
    [AUTHENTICATED] Retrieve all pending CSRs.
    """
198
    return json.loads(self._https('GET', '/csr'))
Vincent Pelletier's avatar
Vincent Pelletier committed
199

200
  def createCertificateSigningRequest(self, csr):
Vincent Pelletier's avatar
Vincent Pelletier committed
201 202 203 204 205 206 207
    """
    [ANONYMOUS] Store a CSR and return its identifier.
    """
    return int(self._http('PUT', '/csr', csr, {
      'Content-Type': 'application/pkcs10',
    }))

208
  def deletePendingCertificateRequest(self, csr_id):
Vincent Pelletier's avatar
Vincent Pelletier committed
209 210 211 212 213
    """
    [AUTHENTICATED] Reject a pending CSR.
    """
    self._https('DELETE', '/csr/%i' % (csr_id, ))

214
  def _getCertificate(self, crt_id):
Vincent Pelletier's avatar
Vincent Pelletier committed
215 216
    return self._http('GET', '/crt' + crt_id)

217
  def getCertificate(self, csr_id):
Vincent Pelletier's avatar
Vincent Pelletier committed
218 219 220 221
    """
    [ANONYMOUS] Retrieve CRT by its identifier (same as corresponding CRL
    identifier).
    """
222
    return self._getCertificate('/%i' % (csr_id, ))
Vincent Pelletier's avatar
Vincent Pelletier committed
223

224
  def getCACertificate(self):
Vincent Pelletier's avatar
Vincent Pelletier committed
225 226 227
    """
    [ANONYMOUS] Retrieve current CA certificate.
    """
228
    return self._getCertificate('/ca.crt.pem')
Vincent Pelletier's avatar
Vincent Pelletier committed
229

230
  def getCACertificateChain(self):
Vincent Pelletier's avatar
Vincent Pelletier committed
231 232 233 234 235 236 237 238 239 240 241 242 243
    """
    [ANONYMOUS] Retrieve CA certificate chain, with CA certificate N+1 signed
    by CA certificate N, allowing automated CA cert rollout.
    """
    found = False
    previous_ca = trust_anchor = sorted(
      (
        utils.load_ca_certificate(x)
        for x in self._ca_crt_pem_list
      ),
      key=lambda x: x.not_valid_before,
    )[-1]
    result = []
244
    for entry in json.loads(self._getCertificate('/ca.crt.json')):
Vincent Pelletier's avatar
Vincent Pelletier committed
245 246 247 248 249 250 251 252 253 254
      try:
        payload = utils.unwrap(
          entry,
          lambda x: x['old_pem'],
          utils.DEFAULT_DIGEST_LIST,
        )
      except cryptography.exceptions.InvalidSignature:
        continue
      if not found:
        found = utils.load_ca_certificate(
255
          utils.toBytes(payload['old_pem']),
Vincent Pelletier's avatar
Vincent Pelletier committed
256 257 258
        ) == trust_anchor
      if found:
        if utils.load_ca_certificate(
259
          utils.toBytes(payload['old_pem']),
Vincent Pelletier's avatar
Vincent Pelletier committed
260 261
        ) != previous_ca:
          raise ValueError('CA signature chain broken')
262
        new_pem = utils.toBytes(payload['new_pem'])
Vincent Pelletier's avatar
Vincent Pelletier committed
263 264 265 266
        result.append(new_pem)
        previous_ca = utils.load_ca_certificate(new_pem)
    return result

267
  def renewCertificate(self, old_crt, old_key, key_len):
Vincent Pelletier's avatar
Vincent Pelletier committed
268 269 270 271 272 273 274 275 276 277 278 279
    """
    [ANONYMOUS] Request certificate renewal.
    """
    new_key = utils.generatePrivateKey(key_len=key_len)
    return (
      utils.dump_privatekey(new_key),
      self._http(
        'PUT',
        '/crt/renew',
        json.dumps(
          utils.wrap(
            {
280 281
              'crt_pem': utils.toUnicode(utils.dump_certificate(old_crt)),
              'renew_csr_pem': utils.toUnicode(utils.dump_certificate_request(
Vincent Pelletier's avatar
Vincent Pelletier committed
282 283 284 285 286 287 288 289 290 291
                x509.CertificateSigningRequestBuilder(
                ).subject_name(
                  # Note: caucase server ignores this, but cryptography
                  # requires CSRs to have a subject.
                  old_crt.subject,
                ).sign(
                  private_key=new_key,
                  algorithm=utils.DEFAULT_DIGEST_CLASS(),
                  backend=_cryptography_backend,
                ),
292
              )),
Vincent Pelletier's avatar
Vincent Pelletier committed
293 294 295 296 297 298 299 300 301
            },
            old_key,
            utils.DEFAULT_DIGEST,
          ),
        ),
        {'Content-Type': 'application/json'},
      ),
    )

302
  def revokeCertificate(self, crt, key=None):
Vincent Pelletier's avatar
Vincent Pelletier committed
303 304 305 306 307
    """
    Revoke certificate.
    [ANONYMOUS] if key is provided.
    [AUTHENTICATED] if key is missing.
    """
308
    crt = utils.toUnicode(crt)
Vincent Pelletier's avatar
Vincent Pelletier committed
309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344
    if key:
      method = self._http
      data = utils.wrap(
        {
          'revoke_crt_pem': crt,
        },
        utils.load_privatekey(key),
        utils.DEFAULT_DIGEST,
      )
    else:
      method = self._https
      data = utils.nullWrap({
        'revoke_crt_pem': crt,
      })
    method(
      'PUT',
      '/crt/revoke',
      json.dumps(data),
      {'Content-Type': 'application/json'},
    )

  def revokeSerial(self, serial):
    """
    Revoke certificate by serial.

    This method is dangerous ! Prefer revokeCRT whenever possible.

    [AUTHENTICATED]
    """
    self._https(
      'PUT',
      '/crt/revoke',
      json.dumps(utils.nullWrap({'revoke_serial': serial})),
      {'Content-Type': 'application/json'},
    )

345
  def createCertificate(self, csr_id, template_csr=''):
Vincent Pelletier's avatar
Vincent Pelletier committed
346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361
    """
    [AUTHENTICATED] Sign certificate signing request.
    """
    header_dict = {}
    if template_csr:
      header_dict['Content-Type'] = 'application/pkcs10'
    self._https('PUT', '/crt/%i' % (csr_id, ), template_csr, header_dict)

class HTTPSOnlyCaucaseClient(CaucaseClient):
  """
  Like CaucaseClient, but forces anonymous accesses to go through HTTPS as
  well.
  """
  def __init__(self, *args, **kw):
    super(HTTPSOnlyCaucaseClient, self).__init__(*args, **kw)
    self._http_connection = self._https_connection