cli.py 34.4 KB
Newer Older
1
# This file is part of caucase
2
# Copyright (C) 2017-2021  Nexedi SA
3 4 5
#     Alain Takoudjou <alain.takoudjou@nexedi.com>
#     Vincent Pelletier <vincent@nexedi.com>
#
6 7 8
# This program is free software: you can Use, Study, Modify and Redistribute
# it under the terms of the GNU General Public License version 3, or (at your
# option) any later version, as published by the Free Software Foundation.
9
#
10 11 12 13 14
# You can also Link and Combine this program with other software covered by
# the terms of any of the Free Software licenses or any of the Open Source
# Initiative approved licenses and Convey the resulting work. Corresponding
# source of such a combination shall include the source code for all other
# software used.
15
#
16 17 18 19 20
# This program is distributed WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
#
# See COPYING file for full licensing terms.
# See https://www.nexedi.com/licensing for rationale and options.
Vincent Pelletier's avatar
Vincent Pelletier committed
21 22 23
"""
Caucase - Certificate Authority for Users, Certificate Authority for SErvices
"""
24
from __future__ import absolute_import, print_function
Vincent Pelletier's avatar
Vincent Pelletier committed
25
import argparse
26
from binascii import hexlify
Vincent Pelletier's avatar
Vincent Pelletier committed
27
import datetime
28 29 30 31 32
try:
  import http.client as http_client
except ImportError: # pragma: no cover
  # BBB: py2.7
  import httplib as http_client
Vincent Pelletier's avatar
Vincent Pelletier committed
33
import json
34
import os
35
import socket
Vincent Pelletier's avatar
Vincent Pelletier committed
36 37 38 39 40
import struct
import sys
from cryptography import x509
from cryptography.hazmat.backends import default_backend
from . import exceptions
41
from . import utils
42
from . import version
Vincent Pelletier's avatar
Vincent Pelletier committed
43 44 45 46 47
from .client import (
  CaucaseError,
  CaucaseClient,
  HTTPSOnlyCaucaseClient,
)
48

49 50 51
if sys.version_info[0] >= 3: # pragma: no cover
  unicode = str

52
class RetryingCaucaseClient(CaucaseClient):
53 54 55 56 57 58 59 60
  """
  Similar to CaucaseClient, but retries indefinitely on http & socket errors.
  To use in long-lived processes where server may not be available yet, or is
  (hopefuly) temporarily unavailable, etc.

  Retries every 10 seconds.
  """
  _until = staticmethod(utils.until)
61
  _log_file = utils.toUnicodeWritableStream(sys.stdout)
62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78

  def _request(self, connection, method, url, body=None, headers=None):
    while True:
      try:
        return super(RetryingCaucaseClient, self)._request(
          connection=connection,
          method=method,
          url=url,
          body=body,
          headers=headers,
        )
      except (
        socket.error,
        # Note: all exceptions below inherit from httplib.HTTPException,
        # but so do errors which are either sign of code bugs
        # (ImproperConnectionState) or sign of garbage values provided by
        # caller/user, and these should be let through.
79 80 81 82
        http_client.BadStatusLine,
        http_client.LineTooLong,
        http_client.UnknownProtocol,
        http_client.IncompleteRead,
83
      ) as exception:
84
        connection.close() # Resets HTTPConnection state machine.
85 86
        # Note: repr(str(exception)) is nicer than repr(exception), without
        # letting non-printable characters through.
87
        next_try = datetime.datetime.utcnow() + datetime.timedelta(seconds=10)
Vincent Pelletier's avatar
Vincent Pelletier committed
88
        print(
89 90
          u'Got a network error, retrying at %s, %s: %r' % (
            next_try.strftime(u'%Y-%m-%d %H:%M:%S +0000'),
Vincent Pelletier's avatar
Vincent Pelletier committed
91
            exception.__class__.__name__,
92
            unicode(exception),
Vincent Pelletier's avatar
Vincent Pelletier committed
93 94 95
          ),
          file=self._log_file,
        )
96
        self._until(next_try)
97

Vincent Pelletier's avatar
Vincent Pelletier committed
98
_cryptography_backend = default_backend()
99

Vincent Pelletier's avatar
Vincent Pelletier committed
100 101 102 103 104 105 106 107 108 109 110 111 112 113
STATUS_ERROR = 1
STATUS_WARNING = 2
STATUS_CALLBACK_ERROR = 3

MODE_SERVICE = 'service'
MODE_USER = 'user'

class CLICaucaseClient(object):
  """
  CLI functionalities
  """
  # Note: this class it more to reduce local variable scopes (avoiding
  # accidental mixups) in each methods than about API declaration.

114
  def __init__(self, client, stdout, stderr):
Vincent Pelletier's avatar
Vincent Pelletier committed
115
    self._client = client
116 117 118 119 120 121
    self._stdout = stdout
    self._stderr = stderr

  def _print(self, *args, **kw):
    kw.setdefault('file', self._stdout)
    print(*args, **kw)
Vincent Pelletier's avatar
Vincent Pelletier committed
122

123 124 125 126 127 128 129 130 131 132 133 134
  def __enter__(self):
    return self

  def __exit__(self, exc_type, exc_value, traceback):
    self.close()

  def close(self):
    """
    Tell client to close any open connection.
    """
    self._client.close()

Vincent Pelletier's avatar
Vincent Pelletier committed
135 136 137 138 139 140 141 142
  def putCSR(self, csr_path_list):
    """
    --send-csr
    """
    for csr_path in csr_path_list:
      csr_pem = utils.getCertRequest(csr_path)
      # Quick sanity check
      utils.load_certificate_request(csr_pem)
143
      self._print(
144
        self._client.createCertificateSigningRequest(csr_pem),
145
        csr_path,
146
      )
Vincent Pelletier's avatar
Vincent Pelletier committed
147 148 149 150 151 152

  def getCSR(self, csr_id_path_list):
    """
    --get-csr
    """
    for csr_id, csr_path in csr_id_path_list:
153
      csr_pem = self._client.getCertificateSigningRequest(int(csr_id))
154
      with open(csr_path, 'ab') as csr_file:
Vincent Pelletier's avatar
Vincent Pelletier committed
155
        csr_file.write(csr_pem)
156

Vincent Pelletier's avatar
Vincent Pelletier committed
157 158 159 160 161 162 163
  def getCRT(self, warning, error, crt_id_path_list, ca_list):
    """
    --get-crt
    """
    for crt_id, crt_path in crt_id_path_list:
      crt_id = int(crt_id)
      try:
164
        crt_pem = self._client.getCertificate(crt_id)
165 166
      except CaucaseError as exception:
        if exception.args[0] != http_client.NOT_FOUND:
Vincent Pelletier's avatar
Vincent Pelletier committed
167 168
          raise
        try:
169
          self._client.getCertificateSigningRequest(crt_id)
170 171
        except CaucaseError as csr_exception:
          if csr_exception.args[0] != http_client.NOT_FOUND:
Vincent Pelletier's avatar
Vincent Pelletier committed
172
            raise
173
          self._print(crt_id, 'not found - maybe CSR was rejected ?')
Vincent Pelletier's avatar
Vincent Pelletier committed
174 175
          error = True
        else:
176
          self._print(crt_id, 'CSR still pending')
Vincent Pelletier's avatar
Vincent Pelletier committed
177 178
          warning = True
      else:
179
        self._print(crt_id, end=' ')
Vincent Pelletier's avatar
Vincent Pelletier committed
180 181 182 183 184
        if utils.isCertificateAutoSigned(utils.load_certificate(
          crt_pem,
          ca_list,
          None,
        )):
185
          self._print('was (originally) automatically approved')
Vincent Pelletier's avatar
Vincent Pelletier committed
186
        else:
187
          self._print('was (originally) manually approved')
Vincent Pelletier's avatar
Vincent Pelletier committed
188 189 190 191
        if os.path.exists(crt_path):
          try:
            key_pem = utils.getKey(crt_path)
          except ValueError:
192 193
            self._print(
              'Expected to find exactly one privatekey key in %s, skipping' % (
Vincent Pelletier's avatar
Vincent Pelletier committed
194
                crt_path,
195
              ),
196
              file=self._stderr,
Vincent Pelletier's avatar
Vincent Pelletier committed
197 198 199 200 201 202
            )
            error = True
            continue
          try:
            utils.validateCertAndKey(crt_pem, key_pem)
          except ValueError:
203 204
            self._print(
              'Key in %s does not match retrieved certificate, skipping' % (
205 206
                crt_path,
              ),
207
              file=self._stderr,
Vincent Pelletier's avatar
Vincent Pelletier committed
208 209 210
            )
            error = True
            continue
211
        with open(crt_path, 'ab') as crt_file:
Vincent Pelletier's avatar
Vincent Pelletier committed
212 213
          crt_file.write(crt_pem)
    return warning, error
214

Vincent Pelletier's avatar
Vincent Pelletier committed
215 216 217 218 219 220 221 222
  def revokeCRT(self, error, crt_key_list):
    """
    --revoke-crt
    """
    for crt_path, key_path in crt_key_list:
      try:
        crt, key, _ = utils.getKeyPair(crt_path, key_path)
      except ValueError:
223 224
        self._print(
          'Could not find (exactly) one matching key pair in %s, skipping' % (
225 226
            [x for x in set((crt_path, key_path)) if x],
          ),
227
          file=self._stderr,
Vincent Pelletier's avatar
Vincent Pelletier committed
228 229 230
        )
        error = True
        continue
231
      self._client.revokeCertificate(crt, key)
Vincent Pelletier's avatar
Vincent Pelletier committed
232
    return error
233

Vincent Pelletier's avatar
Vincent Pelletier committed
234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252
  def renewCRT(
    self,
    crt_key_list,
    renewal_deadline,
    key_len,
    ca_certificate_list,
    updated,
    error,
  ):
    """
    --renew-crt
    """
    for crt_path, key_path in crt_key_list:
      try:
        old_crt_pem, old_key_pem, key_path = utils.getKeyPair(
          crt_path,
          key_path,
        )
      except ValueError:
253 254
        self._print(
          'Could not find (exactly) one matching key pair in %s, skipping' % (
255 256
            [x for x in set((crt_path, key_path)) if x],
          ),
257
          file=self._stderr,
Vincent Pelletier's avatar
Vincent Pelletier committed
258 259 260 261
        )
        error = True
        continue
      try:
Vincent Pelletier's avatar
Vincent Pelletier committed
262 263 264 265 266
        old_crt = utils.load_certificate(
          old_crt_pem,
          ca_certificate_list,
          None,
        )
Vincent Pelletier's avatar
Vincent Pelletier committed
267
      except exceptions.CertificateVerificationError:
268
        self._print(
269
          crt_path,
270
          'was not signed by this CA, revoked or otherwise invalid, skipping',
Vincent Pelletier's avatar
Vincent Pelletier committed
271 272 273
        )
        continue
      if renewal_deadline < old_crt.not_valid_after:
274
        self._print(crt_path, 'did not reach renew threshold, not renewing')
Vincent Pelletier's avatar
Vincent Pelletier committed
275
        continue
276
      new_key_pem, new_crt_pem = self._client.renewCertificate(
Vincent Pelletier's avatar
Vincent Pelletier committed
277 278 279 280 281
        old_crt=old_crt,
        old_key=utils.load_privatekey(old_key_pem),
        key_len=key_len,
      )
      if key_path is None:
282
        with open(crt_path, 'wb') as crt_file:
Vincent Pelletier's avatar
Vincent Pelletier committed
283 284 285
          crt_file.write(new_key_pem)
          crt_file.write(new_crt_pem)
      else:
286 287 288 289 290 291 292
        with open(
          crt_path,
          'wb',
        ) as crt_file, open(
          key_path,
          'wb',
        ) as key_file:
Vincent Pelletier's avatar
Vincent Pelletier committed
293 294 295 296 297 298 299 300 301
          key_file.write(new_key_pem)
          crt_file.write(new_crt_pem)
      updated = True
    return updated, error

  def listCSR(self, mode):
    """
    --list-csr
    """
302 303 304 305 306
    self._print('-- pending', mode, 'CSRs --')
    self._print(
      '%20s | %s' % (
        'csr_id',
        'subject preview (fetch csr and check full content !)',
307
      ),
Vincent Pelletier's avatar
Vincent Pelletier committed
308
    )
309
    for entry in self._client.getPendingCertificateRequestList():
310
      csr = utils.load_certificate_request(utils.toBytes(entry['csr']))
311 312 313 314
      self._print(
        '%20s | %r' % (
          entry['id'],
          repr(csr.subject),
315
        ),
Vincent Pelletier's avatar
Vincent Pelletier committed
316
      )
317
    self._print('-- end of pending', mode, 'CSRs --')
Vincent Pelletier's avatar
Vincent Pelletier committed
318 319 320 321 322 323

  def signCSR(self, csr_id_list):
    """
    --sign-csr
    """
    for csr_id in csr_id_list:
324
      self._client.createCertificate(int(utils.toUnicode(csr_id)))
Vincent Pelletier's avatar
Vincent Pelletier committed
325 326 327 328 329 330

  def signCSRWith(self, csr_id_path_list):
    """
    --sign-csr-with
    """
    for csr_id, csr_path in csr_id_path_list:
331
      self._client.createCertificate(
332
        int(utils.toUnicode(csr_id)),
Vincent Pelletier's avatar
Vincent Pelletier committed
333 334 335 336 337 338 339 340
        template_csr=utils.getCertRequest(csr_path),
      )

  def rejectCSR(self, csr_id_list):
    """
    --reject-csr
    """
    for csr_id in csr_id_list:
341
      self._client.deletePendingCertificateRequest(int(csr_id))
Vincent Pelletier's avatar
Vincent Pelletier committed
342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358

  def revokeOtherCRT(self, crt_list):
    """
    --revoke-other-crt
    """
    for crt_path in crt_list:
      try:
        # Note: also raises when there are serveral certs. This is intended:
        # revoking many certs from a single file seems a dubious use-case
        # (especially in the automated issuance context, which is supposed to
        # be caucase's main target), with high risk if carried without
        # questions (too many certificates revoked, or asingle unexpected one
        # among these, ...) and unambiguous solution is easy (if a human is
        # involved, as is likely the case, more or less directly, for
        # authenticated revocations).
        crt_pem = utils.getCert(crt_path)
      except ValueError:
359 360
        self._print(
          'Could not load a single certificate in %s, skipping' % (
Vincent Pelletier's avatar
Vincent Pelletier committed
361
            crt_path,
362
          ),
363
          file=self._stderr,
Vincent Pelletier's avatar
Vincent Pelletier committed
364
        )
365
      self._client.revokeCertificate(crt_pem)
Vincent Pelletier's avatar
Vincent Pelletier committed
366 367 368 369 370 371 372 373

  def revokeSerial(self, serial_list):
    """
    --revoke-serial
    """
    for serial in serial_list:
      self._client.revokeSerial(serial)

374
def main(argv=None, stdout=sys.stdout, stderr=sys.stderr):
375
  """
Vincent Pelletier's avatar
Vincent Pelletier committed
376
  Command line caucase client entry point.
377
  """
378 379
  parser = argparse.ArgumentParser(
    description='caucase',
380 381 382 383
  )
  parser.add_argument(
    '--version',
    action='version',
384 385
    version=version.__version__,
  )
Vincent Pelletier's avatar
Vincent Pelletier committed
386 387
  # XXX: currently, it is the server which chooses which digest is used to sign
  # stuff.
Vincent Pelletier's avatar
Vincent Pelletier committed
388 389 390 391 392 393 394 395 396 397 398
  # Should clients be able to tell it how to sign (and server could reject) ?
  parser.add_argument(
    '--ca-url',
    required=True,
    metavar='URL',
    help='caucase service HTTP base URL.',
  )
  parser.add_argument(
    '--ca-crt',
    default='cas.crt.pem',
    metavar='CRT_PATH',
399 400 401 402 403 404 405 406
    help='Services CA certificate file location. '
    'May be an existing directory or file, or non-existing. '
    'If non-existing and given path has an extension, a file will be created, '
    'otherwise a directory will be. '
    'When it is a file, it may contain multiple PEM-encoded concatenated '
    'certificates. When it is a directory, it may contain multiple files, '
    'each containing a single PEM-encoded certificate. '
    'default: %(default)s',
Vincent Pelletier's avatar
Vincent Pelletier committed
407 408 409 410 411
  )
  parser.add_argument(
    '--user-ca-crt',
    default='cau.crt.pem',
    metavar='CRT_PATH',
412 413 414 415 416 417 418 419
    help='Users CA certificate file location. '
    'May be an existing directory or file, or non-existing. '
    'If non-existing and given path has an extension, a file will be created, '
    'otherwise a directory will be. '
    'When it is a file, it may contain multiple PEM-encoded concatenated '
    'certificates. When it is a directory, it may contain multiple files, '
    'each containing a single PEM-encoded certificate. '
    'default: %(default)s',
Vincent Pelletier's avatar
Vincent Pelletier committed
420 421 422 423 424
  )
  parser.add_argument(
    '--crl',
    default='cas.crl.pem',
    metavar='CRL_PATH',
425 426 427 428 429 430 431 432
    help='Services certificate revocation list location. '
    'May be an existing directory or file, or non-existing. '
    'If non-existing and given path has an extension, a file will be created, '
    'otherwise a directory will be. '
    'When it is a file, it may contain multiple PEM-encoded concatenated '
    'CRLs. When it is a directory, it may contain multiple files, each '
    'containing a single PEM-encoded CRL. '
    'default: %(default)s',
Vincent Pelletier's avatar
Vincent Pelletier committed
433 434 435 436 437
  )
  parser.add_argument(
    '--user-crl',
    default='cau.crl.pem',
    metavar='CRL_PATH',
438 439 440 441 442 443 444 445
    help='Users certificate revocation list location. '
    'May be an existing directory or file, or non-existing. '
    'If non-existing and given path has an extension, a file will be created, '
    'otherwise a directory will be. '
    'When it is a file, it may contain multiple PEM-encoded concatenated '
    'CRLs. When it is a directory, it may contain multiple files, each '
    'containing a single PEM-encoded CRL. '
    'default: %(default)s',
Vincent Pelletier's avatar
Vincent Pelletier committed
446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502
  )
  parser.add_argument(
    '--threshold',
    default=31,
    type=float,
    help='The remaining certificate validity period, in days, under '
    'which a renew is desired. default: %(default)s',
  )
  parser.add_argument(
    '--key-len',
    default=2048,
    type=int,
    metavar='BITLENGTH',
    help='Number of bits to use when generating a new private key. '
    'default: %(default)s',
  )
  parser.add_argument(
    '--on-renew',
    metavar='EXECUTABLE_PATH',
    help='Path of an executable file to call after any renewal (CA cert, '
    'certificate, revocation list).',
  )
  parser.add_argument(
    '--user-key',
    metavar='KEY_PATH',
    help='User private key and certificate bundled in a single file to '
    'authenticate with caucase.',
  )
  parser.add_argument(
    '--mode',
    default=MODE_SERVICE,
    choices=[MODE_SERVICE, MODE_USER],
    help='The type of certificates you want to manipulate: '
    '<%s> certificates allow managing caucase server, '
    '<%s> certificates can be validated by caucase\'s CA certificate. '
    'default: %%(default)s' % (
      MODE_USER,
      MODE_SERVICE,
    )
  )

  anonymous_group = parser.add_argument_group(
    'Anonymous actions',
    'Actions which do no require authentication.',
  )
  anonymous_group.add_argument(
    '--send-csr',
    nargs='+',
    metavar='CSR_PATH',
    default=[],
    help='Request signature of these certificate signing requests.',
  )
  anonymous_group.add_argument(
    '--get-crt',
    nargs=2,
    action='append',
    default=[],
503
    metavar=('CSR_ID', 'CRT_PATH'),
Vincent Pelletier's avatar
Vincent Pelletier committed
504 505
    help='Retrieve the certificate identified by '
    'identifier and store it at given path. '
506
    'If CRT_PATH exists and contains the private key corresponding to '
Vincent Pelletier's avatar
Vincent Pelletier committed
507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546
    'received certificate, certificate will be appended to that file. '
    'Can be given multiple times.',
  )
  anonymous_group.add_argument(
    '--revoke-crt',
    nargs=2,
    action='append',
    default=[],
    metavar=('CRT_PATH', 'KEY_PATH'),
    help='Revoke certificate. If CRT_PATH file contains both a certificate '
    'and a key, KEY_PATH is ignored. '
    'Can be given multiple times.',
  )
  anonymous_group.add_argument(
    '--renew-crt',
    nargs=2,
    default=[],
    action='append',
    metavar=('CRT_PATH', 'KEY_PATH'),
    help='Renew certificates in-place if they exceed THRESHOLD. '
    'If CRT_PATH file contains both certificate and key, KEY_PATH is ignored '
    'and CRT_PATH receives both the new key and the new certificate. '
    'Can be given multiple times.',
  )
  anonymous_group.add_argument(
    '--get-csr',
    nargs=2,
    default=[],
    action='append',
    metavar=('CSR_ID', 'CSR_PATH'),
    help='Retrieve certificate signing request and append to CSR_PATH. '
    'Should only be needed before deciding to sign or reject the request. '
    'Can be given multiple times.',
  )
  anonymous_group.add_argument(
    '--update-user',
    action='store_true',
    help='Update or create user CA and CRL. '
    'Should only be needed by the https server in front of caucase.'
  )
547

Vincent Pelletier's avatar
Vincent Pelletier committed
548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600
  authenticated_group = parser.add_argument_group(
    'Authenticated actions',
    'Actions which require an authentication. Requires --user-key .',
  )
  authenticated_group.add_argument(
    '--list-csr',
    action='store_true',
    help='List certificate signing requests currently pending on server.',
  )
  authenticated_group.add_argument(
    '--sign-csr',
    nargs='+',
    default=[],
    metavar='CSR_ID',
    help='Sign pending certificate signing requests.',
  )
  authenticated_group.add_argument(
    '--sign-csr-with',
    nargs=2,
    default=[],
    action='append',
    metavar=('CSR_ID', 'CSR_PATH'),
    help='Sign pending certificate signing request, but use provided CSR for '
    'requested subject and extensions instead of stored CSR. '
    'Can be given multiple times.',
  )
  authenticated_group.add_argument(
    '--reject-csr',
    nargs='+',
    default=[],
    metavar='CSR_ID',
    help='Reject these pending certificate signing requests.',
  )
  authenticated_group.add_argument(
    '--revoke-other-crt',
    nargs='+',
    default=[],
    metavar='CRT_PATH',
    help='Revoke certificate without needing access to their private key.'
  )
  authenticated_group.add_argument(
    '--revoke-serial',
    nargs='+',
    default=[],
    metavar='SERIAL',
    type=int,
    help='Revoke certificate by serial number, without needing the '
    'certificate at all. DANGEROUS: typos will not be detected ! '
    'COSTLY: revocation will stay in the revocation list until all '
    'currently valid CA certificates have expired. '
    'Use --revoke and --revoke-other-crt whenever possible.',
  )
  args = parser.parse_args(argv)
601 602
  stdout = utils.toUnicodeWritableStream(stdout)
  stderr = utils.toUnicodeWritableStream(stderr)
Vincent Pelletier's avatar
Vincent Pelletier committed
603 604 605 606 607 608 609 610

  sign_csr_id_set = set(args.sign_csr)
  sign_with_csr_id_set = {x for x, _ in args.sign_csr_with}
  if (
    sign_csr_id_set.intersection(args.reject_csr) or
    sign_with_csr_id_set.intersection(args.reject_csr) or
    sign_csr_id_set.intersection(sign_with_csr_id_set)
  ):
611
    print(
612 613 614
      'A given CSR_ID cannot be in more than one of --sign-csr, '
      '--sign-csr-with and --reject-csr',
      file=stderr,
Vincent Pelletier's avatar
Vincent Pelletier committed
615 616 617 618 619 620 621 622 623 624 625 626
    )
    raise SystemExit(STATUS_ERROR)

  updated = False
  warning = False
  error = False
  finished = False
  cau_url = args.ca_url + '/cau'
  cas_url = args.ca_url + '/cas'

  try:
    # Get a working, up-to-date CAS CA certificate file.
627
    updated |= CaucaseClient.updateCAFile(cas_url, args.ca_crt)
Vincent Pelletier's avatar
Vincent Pelletier committed
628 629
    # --update-user, CA part
    if args.update_user or args.mode == MODE_USER:
630
      updated |= CaucaseClient.updateCAFile(cau_url, args.user_ca_crt)
Vincent Pelletier's avatar
Vincent Pelletier committed
631

632
    with CLICaucaseClient(
Vincent Pelletier's avatar
Vincent Pelletier committed
633 634 635 636 637 638 639 640
      client=CaucaseClient(
        ca_url={
          MODE_SERVICE: cas_url,
          MODE_USER: cau_url,
        }[args.mode],
        ca_crt_pem_list=utils.getCertList(args.ca_crt),
        user_key=args.user_key,
      ),
641 642
      stdout=stdout,
      stderr=stderr,
643
    ) as client:
644 645
      ca_list = utils.load_valid_ca_certificate_list(
        ca_pem_list=utils.getCertList({
646 647
          MODE_SERVICE: args.ca_crt,
          MODE_USER: args.user_ca_crt,
648 649
        }[args.mode]),
      )
650 651 652 653 654 655 656
      client.putCSR(args.send_csr)
      client.getCSR(args.get_csr)
      warning, error = client.getCRT(warning, error, args.get_crt, ca_list)
      error = client.revokeCRT(error, args.revoke_crt)
      updated, error = client.renewCRT(
        crt_key_list=args.renew_crt,
        renewal_deadline=datetime.datetime.utcnow() + datetime.timedelta(
657
          days=args.threshold,
658 659 660 661 662 663 664 665 666 667 668 669 670 671
        ),
        key_len=args.key_len,
        ca_certificate_list=ca_list,
        updated=updated,
        error=error,
      )
      client.signCSR(args.sign_csr)
      client.signCSRWith(args.sign_csr_with)
      client.rejectCSR(args.reject_csr)
      client.revokeOtherCRT(args.revoke_other_crt)
      client.revokeSerial(args.revoke_serial)
      # show latest CSR list status
      if args.list_csr:
        client.listCSR(args.mode)
Vincent Pelletier's avatar
Vincent Pelletier committed
672
    # update our CRL after all revocations we were requested
673 674 675 676 677 678 679
    updated |= CaucaseClient.updateCRLFile(
      cas_url,
      args.crl,
      utils.load_valid_ca_certificate_list(
        ca_pem_list=utils.getCertList(args.ca_crt),
      ),
    )
Vincent Pelletier's avatar
Vincent Pelletier committed
680 681
    # --update-user, CRL part
    if args.update_user:
682 683 684 685 686 687 688
      updated |= CaucaseClient.updateCRLFile(
        cau_url,
        args.user_crl,
        utils.load_valid_ca_certificate_list(
          ca_pem_list=utils.getCertList(args.user_ca_crt),
        ),
      )
Vincent Pelletier's avatar
Vincent Pelletier committed
689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707
    finished = True
  finally:
    if updated and args.on_renew:
      status = os.system(args.on_renew)
      # Avoid raising if we arrived here because of an exception, to not hide
      # the original problem.
      if finished and status:
        raise SystemExit(STATUS_CALLBACK_ERROR)
  if error:
    raise SystemExit(STATUS_ERROR)
  if warning:
    raise SystemExit(STATUS_WARNING)

def probe(argv=None):
  """
  Verify basic caucase server functionality
  """
  parser = argparse.ArgumentParser(
    description='caucase probe - Verify basic caucase server functionality',
708 709 710 711
  )
  parser.add_argument(
    '--version',
    action='version',
712
    version=version.__version__,
Vincent Pelletier's avatar
Vincent Pelletier committed
713 714 715 716 717 718 719 720
  )
  parser.add_argument(
    'ca_url',
    nargs=1,
    help='caucase service HTTP base URL.',
  )
  ca_url, = parser.parse_args(argv).ca_url
  cas_url = ca_url + '/cas'
721 722
  with CaucaseClient(ca_url=cas_url) as caucase_client:
    http_ca_pem = caucase_client.getCACertificate()
723 724 725 726 727 728 729
    with HTTPSOnlyCaucaseClient(
      ca_url=cas_url,
      ca_crt_pem_list=[http_ca_pem],
    ) as https_client:
      https_ca_pem = https_client.getCACertificate()
    # Retrieve again in case there was a renewal between both calls - we do
    # not expect 2 renewals in very short succession.
730
    http2_ca_pem = caucase_client.getCACertificate()
Vincent Pelletier's avatar
Vincent Pelletier committed
731 732 733
  if https_ca_pem not in (http_ca_pem, http2_ca_pem):
    raise ValueError('http and https do not serve the same caucase database')

734
def updater(argv=None, until=utils.until):
Vincent Pelletier's avatar
Vincent Pelletier committed
735 736 737 738
  """
  Bootstrap certificate and companion files and keep them up-to-date.
  """
  parser = argparse.ArgumentParser(
739
    description='caucase updater - '
Vincent Pelletier's avatar
Vincent Pelletier committed
740
    'Bootstrap certificate and companion files and keep them up-to-date',
741 742 743 744
  )
  parser.add_argument(
    '--version',
    action='version',
745
    version=version.__version__,
Vincent Pelletier's avatar
Vincent Pelletier committed
746 747 748 749 750 751 752 753 754 755 756 757
  )
  parser.add_argument(
    '--ca-url',
    required=True,
    metavar='URL',
    help='caucase service HTTP base URL.',
  )
  parser.add_argument(
    '--cas-ca',
    required=True,
    metavar='CRT_PATH',
    help='Service CA certificate file location used to verify connection '
758 759 760 761 762 763 764 765
    'to caucase. '
    'May be an existing directory or file, or non-existing. '
    'If non-existing and given path has an extension, a file will be created, '
    'otherwise a directory will be. '
    'When it is a file, it may contain multiple PEM-encoded concatenated '
    'certificates. When it is a directory, it may contain multiple files, '
    'each containing a single PEM-encoded certificate. '
    'Will be maintained up-to-date.',
Vincent Pelletier's avatar
Vincent Pelletier committed
766 767 768 769 770 771 772 773
  )
  parser.add_argument(
    '--threshold',
    default=31,
    type=float,
    help='The remaining certificate validity period, in days, under '
    'which a renew is desired. default: %(default)s',
  )
774 775 776 777 778 779 780
  parser.add_argument(
    '--crl-threshold',
    default=7,
    type=float,
    help='The remaining certificate revocation validity period, in days, '
    'under which a new one is requested. default: %(default)s',
  )
Vincent Pelletier's avatar
Vincent Pelletier committed
781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824
  parser.add_argument(
    '--key-len',
    default=2048,
    type=int,
    metavar='BITLENGTH',
    help='Number of bits to use when generating a new private key. '
    'default: %(default)s',
  )
  parser.add_argument(
    '--on-renew',
    metavar='EXECUTABLE_PATH',
    help='Path of an executable file to call after any renewal (CA cert, '
    'certificate, revocation list).',
  )
  parser.add_argument(
    '--max-sleep',
    default=31,
    type=float,
    help='Maximum number of days to sleep for. Allows refreshing the CRL '
    'more often on sensitive services. default: %(default)s',
  )
  parser.add_argument(
    '--mode',
    default=MODE_SERVICE,
    choices=[MODE_SERVICE, MODE_USER],
    help='The type of certificates you want to manipulate: '
    '<%s> certificates allow managing caucase server, '
    '<%s> certificates can be validated by caucase\'s CA certificate. '
    'default: %%(default)s' % (
      MODE_USER,
      MODE_SERVICE,
    )
  )
  parser.add_argument(
    '--csr',
    metavar='CSR_PATH',
    help='Path of your CSR to use for initial request of a certificate for '
    'MODE. Ignored once a certificate exists at the location given by '
    '--crt .',
  )
  parser.add_argument(
    '--key',
    metavar='KEY_PATH',
    help='Path of your private key file. Must always exist when this command '
Vincent Pelletier's avatar
Vincent Pelletier committed
825 826 827
    'is started. Will be updated on certificate renewal. If not provided, '
    'both key and certificate will be stored in the file pointed at by '
    '--crt .',
Vincent Pelletier's avatar
Vincent Pelletier committed
828 829 830 831 832 833 834 835 836 837 838 839
  )
  parser.add_argument(
    '--crt',
    metavar='CRT_PATH',
    help='Path of your certificate for MODE. Will be renewed before '
    'expiration.',
  )
  parser.add_argument(
    '--ca',
    required=True,
    metavar='CRT_PATH',
    help='Path of your CA certificate for MODE. '
840 841 842 843 844 845 846
    'May be an existing directory or file, or non-existing. '
    'If non-existing and given path has an extension, a file will be created, '
    'otherwise a directory will be. '
    'When it is a file, it may contain multiple PEM-encoded concatenated '
    'certificates. When it is a directory, it may contain multiple files, '
    'each containing a single PEM-encoded certificate. '
    'Will be maintained up-to-date.',
Vincent Pelletier's avatar
Vincent Pelletier committed
847 848 849 850 851 852
  )
  parser.add_argument(
    '--crl',
    required=True,
    metavar='CRT_PATH',
    help='Path of your certificate revocation list for MODE. '
853 854 855 856 857 858
    'May be an existing directory or file, or non-existing. '
    'If non-existing and given path has an extension, a file will be created, '
    'otherwise a directory will be. '
    'When it is a file, it may contain multiple PEM-encoded concatenated '
    'CRLs. When it is a directory, it may contain multiple files, each '
    'containing a single PEM-encoded CRL. '
Vincent Pelletier's avatar
Vincent Pelletier committed
859 860 861
    'Will be maintained up-to-date.'
  )
  args = parser.parse_args(argv)
862
  client = None
Vincent Pelletier's avatar
Vincent Pelletier committed
863 864 865 866 867 868
  try:
    cas_url = args.ca_url + '/cas'
    ca_url = {
      MODE_SERVICE: cas_url,
      MODE_USER: args.ca_url + '/cau',
    }[args.mode]
869 870 871 872
    threshold = datetime.timedelta(days=args.threshold)
    crl_threshold = datetime.timedelta(days=args.crl_threshold)
    max_sleep = datetime.timedelta(days=args.max_sleep)
    min_sleep = datetime.timedelta(seconds=60)
873
    updated = RetryingCaucaseClient.updateCAFile(
874 875 876
      cas_url,
      args.cas_ca,
    ) and args.cas_ca == args.ca
877
    client = RetryingCaucaseClient(
Vincent Pelletier's avatar
Vincent Pelletier committed
878 879 880
      ca_url=ca_url,
      ca_crt_pem_list=utils.getCertList(args.cas_ca)
    )
881
    if args.crt and not utils.hasOneCert(args.crt):
882
      print('Bootstraping...')
Vincent Pelletier's avatar
Vincent Pelletier committed
883 884 885
      csr_pem = utils.getCertRequest(args.csr)
      # Quick sanity check before bothering server
      utils.load_certificate_request(csr_pem)
886
      csr_id = client.createCertificateSigningRequest(csr_pem)
887
      print('Waiting for signature of', csr_id)
Vincent Pelletier's avatar
Vincent Pelletier committed
888 889
      while True:
        try:
890
          crt_pem = client.getCertificate(csr_id)
891
        except CaucaseError as e:
892
          if e.args[0] != http_client.NOT_FOUND:
Vincent Pelletier's avatar
Vincent Pelletier committed
893 894 895 896
            raise
          # If server does not know our CSR anymore, getCSR will raise.
          # If it does, we were likely rejected, so exit by letting exception
          # through.
897
          client.getCertificateSigningRequest(csr_id)
Vincent Pelletier's avatar
Vincent Pelletier committed
898
          # Still here ? Ok, wait a bit and try again.
899
          until(datetime.datetime.utcnow() + datetime.timedelta(seconds=60))
Vincent Pelletier's avatar
Vincent Pelletier committed
900
        else:
901
          with open(args.crt, 'ab') as crt_file:
Vincent Pelletier's avatar
Vincent Pelletier committed
902 903 904
            crt_file.write(crt_pem)
          updated = True
          break
905
      print('Bootstrap done')
Vincent Pelletier's avatar
Vincent Pelletier committed
906 907
    next_deadline = datetime.datetime.utcnow()
    while True:
908
      print(
909 910
        'Next wake-up at',
        next_deadline.strftime('%Y-%m-%d %H:%M:%S +0000'),
Vincent Pelletier's avatar
Vincent Pelletier committed
911
      )
912
      now = until(next_deadline)
913
      next_deadline = now + max_sleep
914
      if args.cas_ca != args.ca and RetryingCaucaseClient.updateCAFile(
915 916 917
        cas_url,
        args.cas_ca,
      ):
918
        client.close()
919
        client = RetryingCaucaseClient(
Vincent Pelletier's avatar
Vincent Pelletier committed
920 921 922
          ca_url=ca_url,
          ca_crt_pem_list=utils.getCertList(args.cas_ca)
        )
923
      if RetryingCaucaseClient.updateCAFile(ca_url, args.ca):
924
        print('Got new CA')
Vincent Pelletier's avatar
Vincent Pelletier committed
925
        updated = True
926 927 928
        # Note: CRL expiration should happen several time during CA renewal
        # period, so it should not be needed to keep track of CA expiration
        # for next deadline.
929 930 931
      ca_crt_list = utils.load_valid_ca_certificate_list(
        ca_pem_list=utils.getCertList(args.ca),
      )
932
      if RetryingCaucaseClient.updateCRLFile(ca_url, args.crl, ca_crt_list):
933
        print('Got new CRL')
Vincent Pelletier's avatar
Vincent Pelletier committed
934
        updated = True
935 936 937 938
      for _, crl in utils.iter_valid_crl_list(
        crl_pem_list=utils.getCRLList(args.crl),
        trusted_cert_list=ca_crt_list,
      ):
939 940
        next_deadline = min(
          next_deadline,
941
          crl.next_update - crl_threshold,
942
        )
943 944 945 946
      if args.crt:
        crt_pem, key_pem, key_path = utils.getKeyPair(args.crt, args.key)
        crt = utils.load_certificate(crt_pem, ca_crt_list, None)
        if crt.not_valid_after - threshold <= now:
947
          print('Renewing', args.crt)
948 949 950 951 952 953
          new_key_pem, new_crt_pem = client.renewCertificate(
            old_crt=crt,
            old_key=utils.load_privatekey(key_pem),
            key_len=args.key_len,
          )
          if key_path is None:
954
            with open(args.crt, 'wb') as crt_file:
955 956 957 958 959
              crt_file.write(new_key_pem)
              crt_file.write(new_crt_pem)
          else:
            with open(
              args.crt,
960
              'wb',
961 962
            ) as crt_file, open(
              key_path,
963
              'wb',
964 965 966 967 968 969 970 971
            ) as key_file:
              key_file.write(new_key_pem)
              crt_file.write(new_crt_pem)
          crt = utils.load_certificate(utils.getCert(args.crt), ca_crt_list, None)
          updated = True
        next_deadline = min(
          next_deadline,
          crt.not_valid_after - threshold,
Vincent Pelletier's avatar
Vincent Pelletier committed
972
        )
973 974 975 976
      next_deadline = max(
        next_deadline,
        now + min_sleep,
      )
Vincent Pelletier's avatar
Vincent Pelletier committed
977 978 979 980
      if updated:
        if args.on_renew is not None:
          status = os.system(args.on_renew)
          if status:
981
            print('Renewal hook exited with status:', status, file=sys.stderr)
Vincent Pelletier's avatar
Vincent Pelletier committed
982 983 984 985 986 987
            raise SystemExit(STATUS_ERROR)
        updated = False
  except (utils.SleepInterrupt, SystemExit):
    # Not intercepting KeyboardInterrupt so interrupting outside of
    # interruptibleSleep shows where the script got interrupted.
    pass
988 989 990
  finally:
    if client is not None:
      client.close()
Vincent Pelletier's avatar
Vincent Pelletier committed
991 992 993 994 995 996 997 998 999 1000

def rerequest(argv=None):
  """
  Produce a new private key and sign a CSR created by copying an existing,
  well-signed CSR.
  """
  parser = argparse.ArgumentParser(
    description='caucase rerequest - '
    'Produce a new private key and sign a CSR created by copying an existing, '
    'well-signed CSR.',
1001 1002 1003 1004
  )
  parser.add_argument(
    '--version',
    action='version',
1005
    version=version.__version__,
Vincent Pelletier's avatar
Vincent Pelletier committed
1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030
  )
  parser.add_argument(
    '--template',
    required=True,
    help='Existing PEM-encodd CSR to use as a template.',
  )
  parser.add_argument(
    '--csr',
    required=True,
    help='Path of produced PEM-encoded CSR.',
  )
  parser.add_argument(
    '--key',
    required=True,
    help='Path of produced PEM-encoded private key.',
  )
  parser.add_argument(
    '--key-len',
    default=2048,
    type=int,
    metavar='BITLENGTH',
    help='Number of bits to use when generating a new private key. '
    'default: %(default)s',
  )
  args = parser.parse_args(argv)
Vincent Pelletier's avatar
Vincent Pelletier committed
1031
  template = utils.load_certificate_request(
1032
    utils.getCertRequest(args.template),
Vincent Pelletier's avatar
Vincent Pelletier committed
1033
  )
Vincent Pelletier's avatar
Vincent Pelletier committed
1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045
  key = utils.generatePrivateKey(key_len=args.key_len)
  csr_pem = utils.dump_certificate_request(
    x509.CertificateSigningRequestBuilder(
      subject_name=template.subject,
      extensions=template.extensions,
    ).sign(
      private_key=key,
      algorithm=utils.DEFAULT_DIGEST_CLASS(),
      backend=_cryptography_backend,
    ),
  )
  key_pem = utils.dump_privatekey(key)
1046
  orig_umask = os.umask(0o177)
Vincent Pelletier's avatar
Vincent Pelletier committed
1047
  try:
1048
    with open(args.key, 'wb') as key_file:
Vincent Pelletier's avatar
Vincent Pelletier committed
1049 1050 1051
      key_file.write(key_pem)
  finally:
    os.umask(orig_umask)
1052
  with open(args.csr, 'wb') as csr_file:
Vincent Pelletier's avatar
Vincent Pelletier committed
1053 1054
    csr_file.write(csr_pem)

1055
def key_id(argv=None, stdout=sys.stdout):
Vincent Pelletier's avatar
Vincent Pelletier committed
1056 1057 1058 1059 1060 1061 1062 1063
  """
  Displays key identifier from private key, and the list of acceptable key
  identifiers for a given backup file.
  """
  parser = argparse.ArgumentParser(
    description='caucase key id - '
    'Displays key identifier from private key, and the list of acceptable key'
    'identifiers for a given backup file.',
1064 1065 1066 1067
  )
  parser.add_argument(
    '--version',
    action='version',
1068
    version=version.__version__,
Vincent Pelletier's avatar
Vincent Pelletier committed
1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083
  )
  parser.add_argument(
    '--private-key',
    nargs='+',
    default=(),
    help='PEM-encoded keys to display the identifier of.',
  )
  parser.add_argument(
    '--backup',
    nargs='+',
    default=(),
    help='Caucase backup files to display acceptable deciphering key '
    'identifiers of.',
  )
  args = parser.parse_args(argv)
1084
  stdout = utils.toUnicodeWritableStream(stdout)
Vincent Pelletier's avatar
Vincent Pelletier committed
1085
  for key_path in args.private_key:
1086 1087 1088
    with open(key_path, 'rb') as key_file:
      print(
        key_path,
1089
        hexlify(
1090 1091 1092
          x509.SubjectKeyIdentifier.from_public_key(
            utils.load_privatekey(key_file.read()).public_key(),
          ).digest,
1093
        ),
1094
        file=stdout,
1095
      )
Vincent Pelletier's avatar
Vincent Pelletier committed
1096
  for backup_path in args.backup:
1097
    print(backup_path, file=stdout)
1098
    with open(backup_path, 'rb') as backup_file:
Vincent Pelletier's avatar
Vincent Pelletier committed
1099
      magic = backup_file.read(8)
1100
      if magic != b'caucase\0':
Vincent Pelletier's avatar
Vincent Pelletier committed
1101 1102 1103 1104 1105 1106
        raise ValueError('Invalid backup magic string')
      header_len, = struct.unpack(
        '<I',
        backup_file.read(struct.calcsize('<I')),
      )
      for key_entry in json.loads(backup_file.read(header_len))['key_list']:
1107
        print(' ', key_entry['id'].encode('utf-8'), file=stdout)