Commit ecd07d22 authored by Vincent Pelletier's avatar Vincent Pelletier

all: Major rework.

- Re-evaluate feature set and REST API.
- switch duration units to days, which are more meaningful than sticking to
  ISO units in this context.
- Implement the "cau" half of "caucase".
  As a consequence flask password authentication mechanism is not needed
  anymore. As HTML UI is not required internally to caucase, and as
  sqlalchemy is not used to its full extend, get rid of these
  dependencies altogether.
- Implement REST HTTP/HTTPS stand-alone server as a layer above WSGI
  application, and integrate HTTPS certificate issuance and renewal
  mechanism to simplify deployment: no middleware needed, so from
  gunicorn dependency.
- Use standard python modules for http client needs.
- Re-evaluate data retention options:
  - unsigned CSRs are kept forever
  - CRTs are stored in CSR table, and a 24 hour expiration is set
  - CA CRTs: (unchanged, expire when past validity period)
  - CRLs: (unchanged, expire when past validity period)
- Redispatch housekeeping tasks:
  - CA renewal happens when caucase is used and renewal is needed
  - CRL is flushed when re-generated
  - CSR table (containing CRTs) is cleaned when a new CSR is received
  removing completely the need for these special periodic tasks.
- Storage parameters are not stored persistently anymore, instead their
  effect (time offsets) is applied before storing (to protect against
  transient retention period reconfiguration from wiping data).
- Rework storage schema.
- Implement certificate extension propagation & filtering.
- Implement "Certificate was auto-signed" extension.
- More docstrings.
- Use a CSR as a subject & extensions template instead of only allowing
  to override the subject. Useful when renewing a certificate and when
  authenticated client wants to force (ex) a CommonName in the subject.
- Reorganise cli executable arguments to have more possible actions.
  Especially, make CA renewal systematic on command start (helps
  validating caucase URL).
- Increase the amount of sanity checks against user-provided data (ex:
  do not upload a private key which would be in the same file as the CRT
  to renew).
- Extend package classifiers.
- Get rid of revocation reason, as it seems unlikely to be filled, and
  even less likely to be read later.
- (almost) stop using pyOpenSSL. Use cryptography module instead.
  cryptography has many more features than pyOpenSSL (except for certificate
  validation, sadly), so use it. It completely removes the need to poke
  at ASN.1 ourselves, which significantly simplifies utils module, and
  certificate signature. Code is a bit more verbose when signing, but much
  simpler than before.
- add the possibility to revoke by certificate serial
- update gitignore
- include coverage configuration
- include pylint configuration
- integrate several secondary command:
  - caucase-probe to quickly check server presence and basic
    functionality, so automated deployments can easily auto-check
  - caucase-monitor to automate key initial request and renewal
  - caucase-rerequest to allow full flexibility over certificate request
    content without ever transfering private keys
- add a secure backup generation mechanism
- add a README describing the design
parent 0da27fdc
[run]
branch = true
concurrency =
thread
multiprocessing
parallel = true
/htmlcov/
/cover/
/.eggs/
/.coverage
.*.swp
*.pyc *.pyc
[MESSAGES CONTROL]
disable=C0103,C0330
# C0103 Disable "Invalid name "%s" (should match %s)"
# C0330 Disable "bad-continuation"
[FORMAT]
indent-string=" "
0.2.0 (2017-08-XX)
==================
* implement the "cau" half of "caucase"
* massive rework: removal of flask dependency, removal of HTML UI, rework of
the REST API, rework of the CLI tools, rework of the WGSI application,
incomatible redesign of the database.
0.1.4 (2017-07-21) 0.1.4 (2017-07-21)
================== ==================
* caucase web parameter 'auto-sign-csr-amount' can be used to set how many csr must be signed automatically. * caucase web parameter 'auto-sign-csr-amount' can be used to set how many csr must be signed automatically.
......
include CHANGES.txt include CHANGES.txt
recursive-include caucase/templates *.html include COPYING
recursive-include caucase/static *.css *.png *.js *.gif
=======
caucase caucase
======= =======
Certificate Authority for Users, Certificate Authority for SErvices Certificate Authority for Users, Certificate Authority for SErvices
Overview
========
The goal of caucase is to automate certificate issuance and renewal without
constraining how the certificate will be used.
For example, there is no assumption that the certificate will be used to
secure HTTP, nor to serve anything at all: you may need certificates to
authenticate users, or sign your mails, or secure an SQL server socket.
As an unfortunate consequence, it is not possible for caucase to automatically
validate a signing request content against a service (ex: as one could check
the certificate for an HTTPS service was requested by someone with the ability
to make it serve a special file).
This also means that, while caucase imposes RFC-recommended constraints on many
certificate fields and extensions to be worthy of trust, it imposes no
constraint at all on subject and alternate subject certificate fields.
To still allow certificates to be used, caucase uses itself to authenticate
users (humans or otherwise) who implement the validation procedure: they tell
caucase what certificates to emit. Once done, any certificate can be
prolungated at a simple request of the key holder while the to-renew
certificate is still valid (not expired, not revoked).
Bootstrapping the system (creating the first service certificate for
_`caucased` to operate on HTTPS, and creating the first user certificate to
control further certificate issuance) works by caucase automatically signing a
set number of certificates upon submission.
Vocabulary
==========
Caucase manipulates the following asymetric cryptography concepts.
- Key pair: A private key and corresponding public key. The public key can be
derived from the private key, but not the other way around. As a consequence,
the private key is itself considered to be a key pair.
- Certificate: A certificate is the assurante, by a certificate authority,
that a given public key and set of attributes belong to an authorised entity.
Abbreviated cert or crt. A certificate is by definition signed by a CA.
- Certificate Authority: An entry, arbitrarily trusted (but worthy of trust by
its actions and decision) which can issue certificates. Abbreviated CA.
- Certificate signing request: A document produced by an entity desiring to get
certified, which they send to a certificate authority. The certificate signing
request contains the public key and desired set of attributes that the CA
should pronounce itself on. The CA has all liberty to issue a different set
of attiributes, or to not issue a certificate.
- Certificate revocation list: Lists the certificates which were issued by a CA
but which should not be trusted annymore. This can happen for a variety of
reasons: the private key was compromised, or its owneing entity should not be
trusted anymore (ex: entity's permission to access to protected service was
revoked).
- PEM: A serialisation mechanism commonly used for various cryptographic data
pieces. It relies on base64 so it is 7-bits-safe (unlike DER), and is very
commonly supported. Caucase exclusively uses PEM format.
Validity period
===============
Cryptographic keys wear out as are used and and as they age.
Of course, they do not bit-rot nor become thinner with use. But each time one
uses a key and each minute an attacker had access to a public key, fractions
of the private key bits are inevitably leaked, weakening it overall.
So keys must be renewed periodically to preserve intended security level. So
there is a limited life span to each certificate, including the ones emitted by
caucase.
The unit duration for caucase-emitted certificates is the "normal" certificate
life span. It default to 93 days from the moment the certificate was signed,
or about 3 months.
Then the CA certificate has a default life span of 4 "normal" certificate
validity periods. As CA renewal happens in caucase without x509-level cross
signing (by decision, to avoid relying on intermediate CA support on
certificate presenter side and instead rely on more widespread
multi-CA-certificate support on virifier side), there is a hard lower bound of
3 validity periods, under which the CA certificate cannot be reliably renewed
without risking certificate validation issues for emitted "normal"
certificates. CA certificate renewal is composed of 2 phases:
- Passive distribution phase: current CA certificate has a remaining life span
of less than 2 "normal" certificate life spans: a new CA certificate is
generated and distributed on-demand (on "normal" certificate renewal and
issuance, on CRL retrieval with caucase tools...), but not used to sign
anything.
- Active use phase: new CA certificate is valid for more than one "normal"
certificate life span. This means that all existing certificates which are
still in active use had to be renewed at least once since the new CA
certificate exists. This means all the certificate holders had the
opportunity to learn about the new CA certificate. So the new CA certificate
starts being used to sign new certificates, and the old CA certificate falls
out of use as its signed "normal" certificates expire.
By default, all caucase tools will generate a new private key unrelated to the
previous one on each certificat renewal.
Lastly, there is another limited validity period, although not for the same
reasons: the list of revoked certificates also has a maximum life span. In
caucase, the CRL is re-generated whenever it is requested and:
- there is no previous CRL
- previous CRL expired
- any revocation happened since previous CRL was created
Commands
========
Caucase provides several commands to work with certificates.
caucase
+++++++
Reference caucase "one-shot" client.
This command is intended to be used for isolated actions:
- listing and signing pending certificate signature requests
- revoking certificates
It is also able to submit certificate signing requests, retrieve signed
certificates, requesting certificate renewals and updating both
CA certificates and revocation lists, but you may be interested in using
_`caucase-monitor` for this instead.
caucase-monitor
+++++++++++++++
Reference caucase certificate renewal daemon.
Monitors a key pair, corresponding CA certificate and CRL, and renew them
before expiration.
When the key-pair lacks a signed certificate, issues a pre-existing CSR to
caucase server and waits for the certificate to be issued.
caucase-probe
+++++++++++++
Caucase server availability tester.
Performs minimal checks to verify a caucase server is available at given URL.
caucase-rerequest
+++++++++++++++++
Utility allowing to re-issue a CSR using a locally-generated private key.
Intended to be used in conjunction with _`caucase-monitor` when user cannot
generate the CSR on the system where the certificate is desired (ex: automated
HTTPS server deployment), where user is not the intended audience for
caucase-produced certificate:
- User generates a CSR on their own system, and signs it with any key (it will
not be needed later
- User sends the CSR to the system where the certificate is desired
- User gets caucase-rerequest to run on this CSR, producing a new private key
and a CSR similar to issued one, but signed with this new private key
- From then on, caucase-monitor can take over
This way, no private key left their original system, and user could still
freely customise certificate extensions.
caucase-key-id
++++++++++++++
Utility displaying the identifier of given key, or the identifier of keys
involved in given backup file.
Allows identifying users which hold a private key candidate for restoring a
caucased backup (see _`Restoration procedure`).
caucased
++++++++
Reference caucase server daemon.
This daemon provides access to both CAU and CAS services over both HTTP and
HTTPS.
It handles its own certificate issuance and renewal, so there is no need to use
_`caucase-monitor` for this service.
Backups
-------
Loosing the CA private key prevents issuing any new certificate trusted by
services which trusted the CA. Also, it prevents issuing any new CRL.
Recovering from such total loss requires starting a new CA and rolling it out
to all services which used the previous one. This is very time-costly.
So backups are required.
On the other hand, if someone gets their hand on the CA private key, they can
issue certificates for themselves, allowing them to authenticate with services
trusting the CA managed by caucase - including caucased itself if they issue a
user certificate: they can then revoke existing certificates and cause a lot of
damage.
So backups cannot happen in clear text, they must be encrypted.
But the danger of encrypted backups is that by definition they become worthless
if they cannot be decrypted. So as many (trusted) entities as possible should
be granted the ability to decrypt the backups.
The solution proposed by caucased is to encrypt produced backups in a way which
allows any of the caucase users to decrypt the archive.
As these users are already entrusted with issuing certificates, this puts
only a little more power in their hands than they already have. The little
extra power they get is that by having unrestricted access to the CA private
key they can issue certificates bypassing all caucase restrictions. The
proposed parade is to only make the backups available to a limited subset of
caucase users when there is an actual disaster, and otherwise keep it out of
their reach. This mechanism is not handled by caucase.
As there are few trusted users, caucase can keep their still-valid certificates
in its database for the duration of their validity with minimal size cost.
Backup procedure
----------------
Backups happen periodically as long as caucased is running. See
`--backup-period` and `--backup-directory`.
As discussed above, produced files should be kept out of reach of caucase
users until a disaster happens.
Restoration procedure
---------------------
See `--restore-backup`.
To restore, one of the trusted users must voluntarily compromise their own
private key, providing it to the administrator in charge of the restoration
procedure. Restoration procedure will hence immediately revoke their
certificate. They must also provide a CSR generated with a different private
key, so that caucase can provide them with a new certificate, so they keep
their access only via different credentials.
- admin identifies the list of keys which can decipher a backup, and broadcasts
that list to key holders
- key holders manifest themselves
- admin picks a key holder, requests them to provide their eixsting private key
and to generate a new key and accompanying csr
- key holder provide requested items
- admin initiates restoration with `--restore-backup` and provides key holder
with the csr_id so they can fetch their new certificate using caucase
protocol
Backup file format
------------------
- 64bits: 'caucase\0' magic string
- 32bits LE: header length
- header: json-encoded header (see below)
- encrypted byte stream (aka payload)
Header schema (inspired from s/mime, but s/mime tools available do not
support at least iterative production or iterative generation)::
{
"description": "Caucase backup header",
"required": ["algorithm", "key_list"],
"properties": {
"cipher": {
"description": "Symetric ciher used for payload",
"required": ["name"],
"properties": {
"name":
"enum": ["aes256_cbc_pkcs7_hmac_10M_sha256"],
"type": "string"
},
"parameter": {
"description": "Name-dependend clear cipher parameter (ex: IV)",
"type": "string"
}
}
"type": "object"
},
"key_list": {
"description": "Content key, encrypted with public keys",
"minItems": 1,
"items": {
"required": ["id", "cipher", "key"],
"properties": {
"id": {
"description": "Hex-encoded sha1 hash of the public key",
"type": "string"
},
"cipher": {
"description": "Asymetric cipher used for symetric key",
"required": ["name"],
"properties": {
"name": {
"enum": ["rsa_oaep_sha1_mgf1_sha1"],
"type": "string"
}
},
"type": "object"
}
"key": {
"description": "Hex-encoded encrypted concatenation of signing and symetric encryption keys",
"type": "string"
}
},
"type": "object"
},
"type": "array"
}
},
"type": "object"
}
Blocker for 1.0
===============
- After pyca/cryptography 21st release: Make is_signature_valid call mandatory in caucase.utils.load_crl .
- After pyca/cryptography later release (code not fixed yet): Enable CRL distribution point extension when it tolerates literal IPv6 in the URL.
Eventually
==========
- Become an OCSP responder (requires support in other libraries - likely pyca/cryptography).
...@@ -15,23 +15,6 @@ ...@@ -15,23 +15,6 @@
# #
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with caucase. If not, see <http://www.gnu.org/licenses/>. # along with caucase. If not, see <http://www.gnu.org/licenses/>.
"""
# See http://peak.telecommunity.com/DevCenter/setuptools#namespace-packages Caucase - Certificate Authority for Users, Certificate Authority for SErvices
try: """
__import__('pkg_resources').declare_namespace(__name__)
except ImportError:
from pkgutil import extend_path
__path__ = extend_path(__path__, __name__)
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
app = Flask(__name__)
# Use default value so SQLALCHEMY will not warn because there is not db_uri
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///ca.db'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
db = SQLAlchemy(app)
from caucase import web, storage
\ No newline at end of file
# -*- coding: utf-8 -*-
# This file is part of caucase # This file is part of caucase
# Copyright (C) 2017 Nexedi # Copyright (C) 2017 Nexedi
# Alain Takoudjou <alain.takoudjou@nexedi.com> # Alain Takoudjou <alain.takoudjou@nexedi.com>
...@@ -16,541 +15,907 @@ ...@@ -16,541 +15,907 @@
# #
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with caucase. If not, see <http://www.gnu.org/licenses/>. # 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 json import json
import os import os
import sys import struct
import subprocess from cryptography import x509
import re from cryptography.hazmat.backends import default_backend
import time from cryptography.hazmat.primitives import serialization, hashes, hmac
import uuid from cryptography.hazmat.primitives.asymmetric import rsa
import errno from cryptography.hazmat.primitives.asymmetric.padding import OAEP, MGF1
import tempfile from cryptography.hazmat.primitives import padding
from OpenSSL import crypto, SSL from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
import traceback from . import utils
from pyasn1.codec.der import encoder as der_encoder from .exceptions import CertificateVerificationError
from pyasn1.type import tag
from pyasn1_modules import rfc2459 __all__ = ('CertificateAuthority', )
from datetime import datetime, timedelta
from caucase.exceptions import (ExpiredCertificate, NotFound, _cryptography_backend = default_backend()
BadCertificateSigningRequest, CertificateVerificationError) _AUTO_SIGNED_NO = 0
_AUTO_SIGNED_YES = 1
from caucase import utils _AUTO_SIGNED_PASSTHROUGH = 2
_SUBJECT_OID_DICT = {
MIN_CA_RENEW_PERIOD = 2 # pylint: disable=bad-whitespace
DEFAULT_DIGEST_LIST = ['sha256', 'sha384', 'sha512'] 'C' : x509.oid.NameOID.COUNTRY_NAME,
SUBJECT_KEY_LIST = ['C', 'ST', 'L', 'OU', 'O', 'CN', 'emailAddress'] 'O' : x509.oid.NameOID.ORGANIZATION_NAME,
'OU': x509.oid.NameOID.ORGANIZATIONAL_UNIT_NAME,
def getX509NameFromDict(**name_dict): 'ST': x509.oid.NameOID.STATE_OR_PROVINCE_NAME,
""" 'CN': x509.oid.NameOID.COMMON_NAME,
Return a new X509Name with the given attributes. 'L' : x509.oid.NameOID.LOCALITY_NAME,
""" 'SN': x509.oid.NameOID.SURNAME,
# XXX There's no other way to get a new X509Name. 'GN': x509.oid.NameOID.GIVEN_NAME,
name = crypto.X509().get_subject() # pylint: enable=bad-whitespace
}
for key, value in name_dict.items(): _BACKUP_MAGIC = 'caucase\0'
setattr(name, key, value) _CONFIG_NAME_AUTO_SIGN_CSR_AMOUNT = 'auto_sign_csr_amount'
return name
def Extension(value, critical):
"""
Avoid oid redundant parameter when creating an extension.
"""
return x509.Extension(
oid=value.oid,
critical=critical,
value=value,
)
class CertificateAuthority(object): class CertificateAuthority(object):
def __init__(self, storage, ca_life_period, ca_renew_period,
crt_life_time, crl_renew_period, digest_list=None,
crl_base_url=None, ca_subject='',
max_csr_amount=50, crt_keep_time=0,
auto_sign_csr_amount=0):
self._storage = storage
self.ca_life_period = ca_life_period
self.digest_list = digest_list
self.crt_life_time = crt_life_time
self.crl_renew_period = crl_renew_period
self.ca_renew_period = ca_renew_period
self.default_digest = 'sha256'
self.crl_base_url = crl_base_url
self.auto_sign_csr_amount = auto_sign_csr_amount
self.extension_manager = utils.X509Extension()
self.mandatory_subject_key_list = ['CN']
self.ca_subject_dict = self._getCASubjectDict(ca_subject)
# XXX - ERR_SSL_SERVER_CERT_BAD_FORMAT on browser
# Because if two certificate has the same serial from a CA with the same CN
# self.ca_subject_dict['CN'] = '%s %s' % (self.ca_subject_dict['CN'], int(time.time()))
if not self.digest_list:
self.digest_list = DEFAULT_DIGEST_LIST
if self.ca_life_period < MIN_CA_RENEW_PERIOD:
raise ValueError("'ca_life_period' value should be upper than %s" % MIN_CA_RENEW_PERIOD)
if self.crl_renew_period > 1:
raise ValueError("'crl_renew_period' is too high and should be less than a certificate life time.")
self.crl_life_time = int(self.crt_life_time * self.crl_renew_period)
self.ca_life_time = int(self.crt_life_time * self.ca_life_period)
self.ca_renew_time = int(self.crt_life_time * self.ca_renew_period)
self._ca_key_pairs_list = self._storage.getCAKeyPairList()
if not self._ca_key_pairs_list:
self.createCAKeyPair()
def _getCASubjectDict(self, ca_subject):
""" """
Parse CA Subject from provided sting format This class implements CA policy and lifetime logic:
- how CA key pair is generated
Ex: /C=XX/ST=State/L=City/OU=OUnit/O=Company/CN=CA Auth/emailAddress=xx@example.com - what x509.3 extensions and attributes are enforced on signed certificates
- CA and CRL automated renewal
"""
def __init__(
self,
storage,
ca_subject_dict=(),
ca_key_size=2048,
crt_life_time=31 * 3, # Approximately 3 months
ca_life_period=4, # Approximately a year
crl_renew_period=0.33, # Approximately a month
crl_base_url=None,
digest_list=utils.DEFAULT_DIGEST_LIST,
auto_sign_csr_amount=0,
lock_auto_sign_csr_amount=False,
):
"""
storage (caucase.storage.Storage)
Persistent storage of certificate authority data.
ca_subject_dict (dict)
Items to use as Certificate Authority certificate subject.
Supported keys are: C, O, OU, ST, CN, L, SN, GN.
ca_key_size (int)
Number of bits to use as Certificate Authority key.
crt_life_time (float)
Validity duration for every issued certificate, in days.
ca_life_period (float)
Number of crt_life_time periods for which Certificate Authority
certificate will be valid.
Must be greater than 3 to allow smooth rollout.
crl_renew_period (float)
Number of crt_life_time periods for which a revocation list is
valid for.
crl_base_url (str)
The CRL distribution URL to include in signed certificates.
None to not declare a CRL distribution point in generated certificates.
Revocations are be functional even if this is None.
digest_list (list of str)
List of digest algorithms considered acceptable for authenticating
renewal and revocation requests, and CA renewal list responses.
The first item will be the one used, others are accepted but not used.
auto_sign_csr_amount (int)
Automatically sign the first <auto_sign_csr_amount> CSRs.
As certificate gets unconditionally emitted and only vital attributes
and extensions are forced during signature, you should choose the
smallest amount possible to get a functional service.
For a typical HTTP(S) caucase service, 1 should be enough for CAS usage
(first service certificate being to serve HTTPS for caucase), and 1 for
CAU usage (first user, which can then sign more user certificate
requests).
To verify nothing accessed the service before intended automated
requests, check issued certificate has an extension with OID:
2.25.285541874270823339875695650038637483517.0
(a message is printed when retrieving the certificate)
This mark is propagated during certificate renewal.
lock_auto_sign_csr_amount (bool)
When given with a true value, auto_sign_csr_amount is stored and the
value given on later instanciation will be ignored.
""" """
ca_subject_dict = {} self._storage = storage
if lock_auto_sign_csr_amount:
regex = r"\/([C|ST|L|O|OU|CN|emailAddress]+)=([\w\s\@\.\d\-_\(\)\,\+:']+)" storage.setConfigOnce(
_CONFIG_NAME_AUTO_SIGN_CSR_AMOUNT,
matches = re.finditer(regex, ca_subject) auto_sign_csr_amount,
for match in matches: )
key = match.group(1) self._auto_sign_csr_amount = int(storage.getConfigOnce(
if not key in SUBJECT_KEY_LIST: _CONFIG_NAME_AUTO_SIGN_CSR_AMOUNT,
raise ValueError("Item %r is not a valid CA Subject key, please" \ auto_sign_csr_amount,
"Check that the provided key is in %s" % (key, ))
SUBJECT_KEY_LIST))
ca_subject_dict[key] = match.group(2)
for key in self.mandatory_subject_key_list:
if key not in ca_subject_dict:
raise ValueError("The subject key '%r' is mandatory." % key)
return ca_subject_dict self._ca_key_size = ca_key_size
self._digest_list = digest_list
self._default_digest_class = getattr(hashes, self.digest_list[0].upper())
self._crt_life_time = datetime.timedelta(crt_life_time, 0)
self._crl_base_url = crl_base_url
self._ca_subject = x509.Name([
x509.NameAttribute(
oid=_SUBJECT_OID_DICT[key],
value=value,
)
for key, value in dict(ca_subject_dict).iteritems()
])
if ca_life_period < 3:
raise ValueError("ca_life_period must be >= 3 to allow CA rollout")
self._crl_life_time = datetime.timedelta(crt_life_time * crl_renew_period, 0)
self._ca_life_time = datetime.timedelta(crt_life_time * ca_life_period, 0)
self._loadCAKeyPairList()
self._renewCAIfNeeded()
@property
def digest_list(self):
"""
Read-only access to digest_list ctor parameter.
"""
return list(self._digest_list)
def _loadCAKeyPairList(self):
ca_key_pair_list = []
for pem_key_pair in self._storage.getCAKeyPairList():
utils.validateCertAndKey(pem_key_pair['crt_pem'], pem_key_pair['key_pem'])
ca_key_pair_list.append({
'crt': utils.load_ca_certificate(pem_key_pair['crt_pem']),
'key': utils.load_privatekey(pem_key_pair['key_pem']),
})
self._ca_key_pairs_list = ca_key_pair_list
def renewCAKeyPair(self): def createCAKeyPair(self):
"""
Refresh instance's knowledge of database content
(as storage house-keeping may/will happen outside our control)
""" """
Create a new CA key pair.
cert = self._ca_key_pairs_list[-1]['crt'] CA certificate renewal normally happens automatically as long as
expire_date = datetime.strptime(cert.get_notAfter(), '%Y%m%d%H%M%SZ') certificates are getting signed and revocation list downloaded.
renew_date = expire_date - timedelta(0, self.ca_renew_time)
if renew_date > datetime.now():
# The ca certificat should not be renewed now
return False
self.createCAKeyPair()
return True
def createCAKeyPair(self):
""" """
Create a new ca key + certificate pair private_key = rsa.generate_private_key(
""" public_exponent=65537,
key_pair = {} key_size=self._ca_key_size,
key = crypto.PKey() backend=_cryptography_backend,
# Use 2048 bits key size )
key.generate_key(crypto.TYPE_RSA, 2048) public_key = private_key.public_key()
subject_key_identifier = x509.SubjectKeyIdentifier.from_public_key(
key_pair['key'] = key public_key,
)
ca = crypto.X509() now = datetime.datetime.utcnow()
# 3 = v3 certificate = x509.CertificateBuilder(
ca.set_version(3) subject_name=self._ca_subject,
ca.set_serial_number(int(time.time())) issuer_name=self._ca_subject,
subject = ca.get_subject() not_valid_before=now,
for name, value in self.ca_subject_dict.items(): not_valid_after=now + self._ca_life_time,
setattr(subject, name, value) serial_number=x509.random_serial_number(),
public_key=public_key,
ca.gmtime_adj_notBefore(0) extensions=[
ca.gmtime_adj_notAfter(self.ca_life_time) Extension(
ca.set_issuer(ca.get_subject()) x509.BasicConstraints(
ca.set_pubkey(key) ca=True,
self.extension_manager.setCaExtensions(ca) path_length=0,
ca.sign(key, self.default_digest) ),
key_pair['crt'] = ca critical=True, # "MUST mark the extension as critical"
),
self._storage.storeCAKeyPair(key_pair) Extension(
self._ca_key_pairs_list = self._storage.getCAKeyPairList() x509.KeyUsage(
# pylint: disable=bad-whitespace
digital_signature =False,
content_commitment=False,
key_encipherment =False,
data_encipherment =False,
key_agreement =False,
key_cert_sign =True,
crl_sign =True,
encipher_only =False,
decipher_only =False,
# pylint: enable=bad-whitespace
),
critical=True, # "SHOULD mark this extension critical"
),
# Should we make use of certificate policies ? If we do, we need to enable
# this extension and fill the values.
# Extension(
# x509.PolicyConstraints(
# require_explicit_policy=,
# inhibit_policy_mapping=,
# ),
# critical=True, # MUST mark this extension as critical
# ),
Extension(
subject_key_identifier,
critical=False, # "MUST mark this extension as non-critical"
),
Extension(
x509.AuthorityKeyIdentifier.from_issuer_subject_key_identifier(
# Dummy extension, from_issuer_subject_key_identifier accesses .data directly
Extension(
subject_key_identifier,
critical=False,
),
),
critical=False, # "MUST mark this extension as non-critical"
),
],
).sign(
private_key=private_key,
algorithm=self._default_digest_class(),
backend=_cryptography_backend,
)
self._storage.appendCAKeyPair(
utils.datetime2timestamp(certificate.not_valid_after),
{
'key_pem': utils.dump_privatekey(private_key),
'crt_pem': utils.dump_certificate(certificate),
},
)
self._loadCAKeyPairList()
assert self._ca_key_pairs_list assert self._ca_key_pairs_list
def getPendingCertificateRequest(self, csr_id): def getCertificateSigningRequest(self, csr_id):
""" """
Retrieve the content of a pending signing request. Retrieve a PEM-encoded certificate signing request.
@param csr_id: The id of CSR returned by the storage csr_id (int)
As returned when the CSR was stored.
""" """
return self._storage.getPendingCertificateRequest(csr_id) return self._storage.getCertificateSigningRequest(csr_id)
def createCertificateSigningRequest(self, csr): def appendCertificateSigningRequest(self, csr_pem, override_limits=False):
""" """
Sanity-check CSR, stores it and generates a unique signing request Store certificate signing request and return its identifier.
identifier (crt_id). May trigger its signature if the quantity of submitted CSRs is less than
auto_sign_csr_amount (see __init__).
@param csr: CSR string in PEM format csr_pem (str)
PEM-encoded certificate signing request.
""" """
# Check number of already-pending signing requests csr = utils.load_certificate_request(csr_pem)
# Check if csr is self-signed # Note: requested_amount is None when a known CSR is re-submitted
# Check it has a CN (?) csr_id, requested_amount = self._storage.appendCertificateSigningRequest(
# Check its extensions csr_pem=csr_pem,
# more ? key_id=x509.SubjectKeyIdentifier.from_public_key(
try: csr.public_key(),
csr_pem = crypto.load_certificate_request(crypto.FILETYPE_PEM, csr) ).digest.encode('hex'),
except crypto.Error, e: override_limits=override_limits,
raise BadCertificateSigningRequest(str(e)) )
if requested_amount is not None and \
if not hasattr(csr_pem.get_subject(), 'CN') or not csr_pem.get_subject().CN: requested_amount <= self._auto_sign_csr_amount:
raise BadCertificateSigningRequest("CSR has no common name set")
# XXX check extensions
csr_id = self._storage.storeCertificateSigningRequest(csr_pem)
if self._storage.getCertificateSigningRequestAmount() <= \
self.auto_sign_csr_amount:
# if allowed to sign this certificate automaticaly # if allowed to sign this certificate automaticaly
self.createCertificate(csr_id) self._createCertificate(csr_id, auto_signed=_AUTO_SIGNED_YES)
return csr_id return csr_id
def deletePendingCertificateRequest(self, csr_id): def deletePendingCertificateSigningRequest(self, csr_id):
""" """
Reject a pending certificate signing request. Reject a pending certificate signing request.
@param csr_id: The id of CSR returned by the storage csr_id (int)
CSR id, as returned when the CSR was stored.
""" """
self._storage.deletePendingCertificateRequest(csr_id) self._storage.deletePendingCertificateSigningRequest(csr_id)
def getPendingCertificateRequestList(self, limit=0, with_data=False): def getCertificateRequestList(self):
""" """
Return list of pending certificate signature request Return the list of pending certificate signature requests, individually
PEM-encoded.
@param limit: number of element to fetch, 0 is not limit (int)
@param with_data: True or False, say if return csr PEM string associated
to others informations (bool).
""" """
return self._storage.getPendingCertificateRequestList(limit, with_data) return self._storage.getCertificateSigningRequestList()
def createCertificate(self, csr_id, ca_key_pair=None, subject_dict=None): def createCertificate(self, csr_id, template_csr=None):
""" """
Generate new signed certificate. `ca_key_pair` is the CA key_pair to use Sign a pending certificate signing request, storing produced certificate.
if None, use the latest CA key_pair
@param csr_id: CSR ID returned by storage, csr should be linked to the csr_id (int)
new certificate (string). CSR id, as returned when the CSR was stored.
@param ca_key_pair: The CA key_pair to used for signature. If None, the template_csr (None or X509Req)
latest key_pair is used. Copy extensions and subject from this CSR instead of stored one.
@param subject_dict: dict of subject attributes to use in x509 subject, Useful to renew a certificate.
if None, csr subject is used (dict). Public key is always copied from stored CSR.
""" """
# Apply extensions (ex: "not a certificate", ...) self._createCertificate(
# Generate a certificate from the CSR csr_id=csr_id,
# Sign the certificate with the current CA key auto_signed=_AUTO_SIGNED_NO,
template_csr=template_csr,
csr_pem = crypto.load_certificate_request(crypto.FILETYPE_PEM, )
self._storage.getPendingCertificateRequest(csr_id))
# Certificate serial is the csr_id without extension .csr.pem def _createCertificate(self, csr_id, auto_signed, template_csr=None):
serial = int(csr_id[:-8], 16) """
subject = None auto_signed (bool)
if ca_key_pair is None: When True, mark certificate as having been auto-signed.
ca_key_pair = self._ca_key_pairs_list[-1] When False, prevent such mark from being set.
if subject_dict: When None, do not filter (useful when renewing).
if subject_dict.has_key('C') and len(subject_dict['C']) != 2: tempate_csr (None or X509Req)
# Country code size is 2 Copy extensions and subject from this CSR instead of stored one.
raise ValueError("Country Code size in subject should be equal to 2.") Useful to renew a certificate.
if not subject_dict.has_key('CN'): Public key is always copied from stored CSR.
raise AttributeError("Attribute 'CN' is required in subject.") """
csr_pem = self._storage.getCertificateSigningRequest(csr_id)
csr = utils.load_certificate_request(csr_pem)
if template_csr is None:
template_csr = csr
ca_key_pair = self._getCurrentCAKeypair()
ca_crt = ca_key_pair['crt']
public_key = csr.public_key()
subject_key_identifier = x509.SubjectKeyIdentifier.from_public_key(
public_key,
)
now = datetime.datetime.utcnow()
builder = x509.CertificateBuilder(
subject_name=template_csr.subject,
issuer_name=ca_crt.subject,
not_valid_before=now,
not_valid_after=now + self._crt_life_time,
serial_number=x509.random_serial_number(),
public_key=public_key,
extensions=[
Extension(
x509.BasicConstraints(
ca=False,
path_length=None,
),
critical=True, # "MAY appear as critical or non-critical"
),
Extension(
subject_key_identifier,
critical=False, # "MUST mark this extension as non-critical"
),
Extension(
x509.AuthorityKeyIdentifier.from_issuer_subject_key_identifier(
ca_crt.extensions.get_extension_for_class(x509.SubjectKeyIdentifier),
),
critical=False, # "MUST mark this extension as non-critical"
),
],
)
# Note: disabled because of following IPv6 bug:
# https://github.com/pyca/cryptography/issues/3863
# if self._crl_base_url:
# builder = builder.add_extension(
# x509.CRLDistributionPoints([
# x509.DistributionPoint(
# full_name=[
# x509.UniformResourceIdentifier(self._crl_base_url),
# ],
# relative_name=None,
# crl_issuer=None,
# reasons=None,
# ),
# ]),
# critical=False, # "SHOULD be non-critical"
# )
try: try:
subject = getX509NameFromDict(**subject_dict) key_usage_extension = template_csr.extensions.get_extension_for_class(
except AttributeError: x509.KeyUsage,
raise AttributeError("X509Name attribute not found. Subject " \ )
"keys should be in %r" % SUBJECT_KEY_LIST) except x509.ExtensionNotFound:
cert_pem = self._generateCertificateObjects(ca_key_pair, pass
csr_pem, else:
serial, key_usage = key_usage_extension.value
subject=subject) if key_usage.key_agreement:
encipher_only = key_usage.encipher_only
crt_id = self._storage.storeCertificate(csr_id, cert_pem) decipher_only = key_usage.decipher_only
return crt_id else:
encipher_only = decipher_only = False
builder = builder.add_extension(
x509.KeyUsage(
# pylint: disable=bad-whitespace
digital_signature =key_usage.digital_signature,
content_commitment=key_usage.content_commitment,
key_encipherment =key_usage.key_encipherment,
data_encipherment =key_usage.data_encipherment,
key_agreement =key_usage.key_agreement,
key_cert_sign =False,
crl_sign =False,
encipher_only =encipher_only,
decipher_only =decipher_only,
# pylint: enable=bad-whitespace
),
critical=key_usage_extension.critical, # "SHOULD mark this extension critical"
)
try:
extended_key_usage = template_csr.extensions.get_extension_for_class(
x509.ExtendedKeyUsage,
)
except x509.ExtensionNotFound:
pass
else:
builder = builder.add_extension(
x509.ExtendedKeyUsage(
[
x for x in extended_key_usage.value
if x != x509.oid.ExtendedKeyUsageOID.OCSP_SIGNING
]
),
critical=extended_key_usage.critical,
)
try:
subject_alt_name = template_csr.extensions.get_extension_for_class(
x509.SubjectAlternativeName,
)
except x509.ExtensionNotFound:
pass
else:
# Note: as issued certificates may be used without any subject
# validation (ex: connecting to mariadb via a variable IP), we
# voluntarily do not enforce any constraint on subjectAltName.
builder = builder.add_extension(
subject_alt_name.value,
critical=subject_alt_name.critical,
)
# subjectDirectoryAttributes ?
try:
certificate_policies = template_csr.extensions.get_extension_for_class(
x509.CertificatePolicies,
)
except x509.ExtensionNotFound:
if auto_signed == _AUTO_SIGNED_YES:
builder = builder.add_extension(
x509.CertificatePolicies([
utils.CAUCASE_POLICY_INFORMATION_AUTO_SIGNED,
]),
critical=False, # (no recommendations)
)
else:
if auto_signed == _AUTO_SIGNED_PASSTHROUGH:
# Caller is asking us to let all through, so do this.
policy_list = certificate_policies.value
else:
# Prevent any caucase extension from being smuggled, especiall the
# "auto-signed" one...
policy_list = [
x for x in certificate_policies.value
if not x.policy_identifier.dotted_string.startswith(utils.CAUCASE_OID_TOP)
]
if auto_signed == _AUTO_SIGNED_YES:
# ...but do add auto-signed extension if we are auto-signing.
policy_list.append(utils.CAUCASE_POLICY_INFORMATION_AUTO_SIGNED)
builder = builder.add_extension(
x509.CertificatePolicies(policy_list),
critical=certificate_policies.critical, # (no recommendations)
)
def getCertificate(self, crt_id): cert_pem = utils.dump_certificate(builder.sign(
""" private_key=ca_key_pair['key'],
Return a Certificate string in PEM format algorithm=self._default_digest_class(),
backend=_cryptography_backend,
))
self._storage.storeCertificate(csr_id, cert_pem)
return cert_pem
@param crt_id: Certificate ID returned by storage during certificate creation def getCertificate(self, csr_id):
""" """
return self._storage.getCertificate(crt_id) Return PEM-encoded signed certificate.
def getCertificateFromSerial(self, serial): csr_id (int)
As returned when the corresponding CSR was stored.
""" """
Return a Certificate string in PEM format return self._storage.getCertificate(csr_id)
@param serial: serial of the certificate (string) def _renewCAIfNeeded(self):
""" """
cert = self._storage.getCertificateFromSerial(serial) Create a new CA certificate if latest one has less than two
if not cert.content: ca_life_periods of validity left.
raise NotFound('Content certificate with serial %r is not found.' % ( Updates self._ca_key_pairs_list .
serial,
))
return cert.content
def getSignedCertificateList(self, limit=0, with_data=False):
""" """
Return list of signed certificate if not self._ca_key_pairs_list or (
self._ca_key_pairs_list[-1]['crt'].not_valid_after - datetime.datetime.utcnow()
).total_seconds() / self._crt_life_time.total_seconds() <= 2:
# No CA certificate at all or less than 2 certificate validity periods
# left with latest CA certificate. Prepare the next one so it starts
# getting distributed.
self.createCAKeyPair()
@param limit: number of element to fetch, 0 is not limit (int) def _getCurrentCAKeypair(self):
@param with_data: True or False, say if return cert PEM string associated """
to others informations (bool). Return the currently-active CA certificate key pair.
"""
return self._storage.getSignedCertificateList(limit, with_data) Currently-active CA certificate is the CA to use when signing. It may not
be the latest one, as all certificate holders must know the latest one
before its use can start.
"""
self._renewCAIfNeeded()
now = datetime.datetime.utcnow()
for key_pair in reversed(self._ca_key_pairs_list):
if key_pair['crt'].not_valid_before + self._crt_life_time < now:
# This CA cert is valid for more than one certificate life time,
# we can assume clients to know it (as they had to renew their
# cert at least once since it became available) so we can start
# using it.
break
else:
# No CA cert is valid for more than one certificate life time, so just pick
# the newest one.
key_pair = self._ca_key_pairs_list[-1]
return key_pair
def getCACertificate(self): def getCACertificate(self):
""" """
Return current CA certificate Return current CA certificate, PEM-encoded.
""" """
return self._dumpCertificate(self._ca_key_pairs_list[-1]['crt']) return utils.dump_certificate(self._getCurrentCAKeypair()['crt'])
def getValidCACertificateChain(self): def getCACertificateList(self):
""" """
Return the ca certificate chain for all valid certificates with key Return the current list of CA certificates as X509 obbjects.
""" """
result = [] self._renewCAIfNeeded()
iter_key_pair = iter(self._ca_key_pairs_list) return [x['crt'] for x in self._ca_key_pairs_list]
previous_key_pair = iter_key_pair.next()
for key_pair in iter_key_pair:
result.append(utils.wrap({
'old': self._dumpCertificate(previous_key_pair['crt']),
'new': self._dumpCertificate(key_pair.crt),
}, self._dumpPrivatekey(previous_key_pair['key']), self.digest_list))
return result
def getCAKeypairForCertificate(self, cert): def getValidCACertificateChain(self):
""" """
Return the nearest CA key_pair to the next extension date of the cert Return the CA certificate chain based on oldest CA certificate.
@param cert: X509 certificate Each item in the chain is a wrapped dict with the following keys:
""" old (str)
cert_valid_date = datetime.strptime(cert.get_notAfter(), '%Y%m%d%H%M%SZ') N-1 certificate as PEM, used to check wrapper signature.
next_valid_date = datetime.utcnow() + timedelta(0, self.crt_life_time) If item is the first in the chain, this is the oldest CA certificate
# check which ca certificate should be used to renew the cert server still knows about.
selected_keypair = None new (str)
selected_date = None N certificate as PEM.
for key_pair in self._ca_key_pairs_list:
expiration_date = datetime.strptime(key_pair['crt'].get_notAfter(), '%Y%m%d%H%M%SZ')
if expiration_date < next_valid_date:
continue
if selected_date and selected_date < expiration_date:
# Only get the lowest expiration_date which cover the renew notbefore date
continue
selected_keypair = key_pair
selected_date = expiration_date
if selected_keypair is None: The intent is for a client knowing one CA certificate to retrieve any newer
raise ValueError("No valid CA key_pair found with validity date upper than %r certificate lifetime" % CA certificate and autonomously decide if it may trust them: each item is
next_valid_date) signed with the previous certificate. The oldest CA certificate is not
returned in this list, as it cannot be signed by another one.
return selected_keypair CA user must check that there is an uninterrupted signed path from its
already-known CA certificate to use any contained "new" certificate.
It must skip any certificate pair for which it does not already trust
an ancestor certificate.
def revokeCertificate(self, wrapped_crt): Note: the chain may contain expired CA certificates. CA user should skip
these, and consider their signature invalid for CA chain validation
purposes.
""" """
Revoke a certificate self._renewCAIfNeeded()
result = []
@param wrapped_crt: The revoke request dict containing certificate to iter_key_pair = iter(self._ca_key_pairs_list)
revoke and signature algorithm used to sign the request. first_key_pair = iter_key_pair.next()
previous_crt_pem = utils.dump_certificate(first_key_pair['crt'])
previous_key = first_key_pair['key']
for key_pair in iter_key_pair:
current_crt_pem = utils.dump_certificate(key_pair['crt'])
result.append(utils.wrap(
{ {
"signature": "signature string for payload", 'old_pem': previous_crt_pem,
"digest": "Signature algorithm (ex: SHA256"), 'new_pem': current_crt_pem,
"payload": dict of data: { },
"revoke_crt": "Certificate to revoke", previous_key,
"reason": "Revoke reason" self.digest_list[0],
} ))
} previous_key = key_pair['key']
""" previous_crt_pem = current_crt_pem
payload = utils.unwrap(wrapped_crt, lambda x: x['revoke_crt'], self.digest_list) return result
try:
x509 = self._loadCertificate(payload['revoke_crt'])
except crypto.Error, e:
raise BadCertificate(str(e))
if not utils.verifyCertificateChain(x509,
[x['crt'] for x in self._ca_key_pairs_list]):
raise CertificateVerificationError("Certificate verification failed:" \
"The CA couldn't reconize the certificate to revoke.")
crt = self._loadCertificate(payload['revoke_crt'])
reason = payload['reason']
return self._storage.revokeCertificate(
utils.getSerialToInt(crt),
reason)
def revokeCertificateFromID(self, crt_id):
"""
Directly revoke a certificate from crt_id
@param crt_id: The ID of the certificate (string) def revoke(self, crt_pem):
""" """
Revoke certificate.
return self._storage.revokeCertificate( crt_pem (str)
crt_id=crt_id, PEM-encoded certificat to revoke.
reason="")
def renew(self, wrapped_csr):
""" """
Renew a certificate crt = utils.load_certificate(
crt_pem,
self.getCACertificateList(),
x509.load_pem_x509_crl(
self.getCertificateRevocationList(),
_cryptography_backend,
),
)
self._storage.revoke(
serial=crt.serial_number,
expiration_date=utils.datetime2timestamp(crt.not_valid_after),
)
@param wrapped_csr: The revoke request dict containing certificate to def revokeSerial(self, serial):
revoke and signature algorithm used to sign the request.
{
"signature": "signature string for payload",
"digest": "Signature algorithm (ex: SHA256"),
"payload": dict of data: {
"crt": "Old certificate to renew",
"renew_csr": "New CSR to sign"
}
}
""" """
payload = utils.unwrap(wrapped_csr, lambda x: x['crt'], self.digest_list) Revoke a certificate by its serial only.
csr = payload['renew_csr']
try:
x509 = self._loadCertificate(payload['crt'])
except crypto.Error, e:
raise BadCertificate(str(e))
try: Revocation will expire when the latest CA certificate of this instance
csr_pem = crypto.load_certificate_request(crypto.FILETYPE_PEM, csr) expires, meaning it will stay longer in the revocation list than when
except crypto.Error, e: certificate expiration date can be retrieved from the certificate.
raise BadCertificateSigningRequest(str(e))
if csr_pem.get_subject().CN != x509.get_subject().CN:
raise BadCertificateSigningRequest(
"Request common name does not match replaced certificate.")
if not self._storage.getCertificateFromSerial(utils.getSerialToInt(x509)):
raise NotFound('No Certificate with serial %r and Common Name %r found.' % (
x509.get_serial_number(),
x509.get_subject().CN,
))
if not utils.verifyCertificateChain(x509, Also, there cannot be any check on the validity of the serial, typos
[x['crt'] for x in self._ca_key_pairs_list]): are accepted verbatim.
raise CertificateVerificationError("Certificate verification failed:" \
"The CA couldn't reconize signed certificate.")
csr_id = self.createCertificateSigningRequest(csr)
# sign the new certificate using a specific ca key_pair
ca_key_pair = self.getCAKeypairForCertificate(x509)
self.createCertificate(csr_id, ca_key_pair)
return csr_id
def getCertificateRevocationList(self): Using this method is hence not recomended.
"""
Generate certificate revocation list PEM
""" """
crl = self._storage.getCertificateRevocationList() self._storage.revoke(
if not crl: serial=serial,
# Certificate revocation list needs to be regenerated expiration_date=utils.datetime2timestamp(max(
return self._createCertificateRevocationList() x.not_valid_after for x in self.getCACertificateList()
)),
return crl )
def _loadCertificate(self, cert_string):
"""
Load certificate in PEM format
"""
return crypto.load_certificate(crypto.FILETYPE_PEM, cert_string)
def _dumpCertificate(self, cert_object): def renew(self, crt_pem, csr_pem):
"""
Dump certificate in PEM format
""" """
return crypto.dump_certificate(crypto.FILETYPE_PEM, cert_object) Renew certificate.
def _loadPrivatekey(self, pkey): crt_pem (str)
PEM-encoded certificate to renew.
csr_pem (str)
PEM-encoded certificate signing request.
""" """
Load private key in PEM format ca_cert_list = self.getCACertificateList()
""" crt = utils.load_certificate(
return crypto.load_privatekey(crypto.FILETYPE_PEM, pkey) crt_pem,
ca_cert_list,
x509.load_pem_x509_crl(
self.getCertificateRevocationList(),
_cryptography_backend,
),
)
return self._createCertificate(
csr_id=self.appendCertificateSigningRequest(csr_pem),
auto_signed=_AUTO_SIGNED_PASSTHROUGH,
# Do a dummy signature, just so we get a usable
# x509.CertificateSigningRequest instance. Use latest CA private key just
# because it is available for free (unlike generating a new one).
template_csr=x509.CertificateSigningRequestBuilder(
subject_name=crt.subject,
extensions=crt.extensions,
).sign(
private_key=self._ca_key_pairs_list[-1]['key'],
algorithm=self._default_digest_class(),
backend=_cryptography_backend,
),
)
def _dumpPrivatekey(self, pkey_object): def getCertificateRevocationList(self):
"""
Load private key in PEM format
""" """
return crypto.dump_privatekey(crypto.FILETYPE_PEM, pkey_object) Return PEM-encoded certificate revocation list.
"""
crl_pem = self._storage.getCertificateRevocationList()
if crl_pem is None:
ca_key_pair = self._getCurrentCAKeypair()
now = datetime.datetime.utcnow()
crl = x509.CertificateRevocationListBuilder(
issuer_name=ca_key_pair['crt'].issuer,
last_update=now,
next_update=now + self._crl_life_time,
extensions=[
Extension(
x509.CRLNumber(
self._storage.getNextCertificateRevocationListNumber(),
),
critical=False, # "MUST mark this extension as non-critical"
),
],
revoked_certificates=[
x509.RevokedCertificateBuilder(
serial_number=x['serial'],
revocation_date=datetime.datetime.fromtimestamp(
x['revocation_date'],
),
).build(_cryptography_backend)
for x in self._storage.getRevocationList()
],
).sign(
private_key=ca_key_pair['key'],
algorithm=self._default_digest_class(),
backend=_cryptography_backend,
)
crl_pem = crl.public_bytes(serialization.Encoding.PEM)
self._storage.storeCertificateRevocationList(
crl_pem,
expiration_date=utils.datetime2timestamp(crl.next_update),
)
return crl_pem
def _generateCertificateObjects(self, ca_key_pair, req, serial, subject=None): class UserCertificateAuthority(CertificateAuthority):
""" """
Generate certificate from CSR PEM Object. Backup-able CertificateAuthority.
This method set default certificate extensions, later will allow to set custom extensions
ca_key_pair: ca_key_pair which should be used to sign certificate
req: csr object to sign
serial: serial to apply to the new signed certificate
subject: give a dict containing new subject to apply on signed certificate
if subject is None, req.get_subject() is used. See backup schema in documentation.
""" """
if subject is None: def doBackup(self, write):
subject = req.get_subject()
# Here comes the actual certificate
cert = crypto.X509()
# version v3
cert.set_version(2)
cert.set_serial_number(serial)
cert.gmtime_adj_notBefore(0)
cert.gmtime_adj_notAfter(self.crt_life_time)
cert.set_issuer(ca_key_pair['crt'].get_subject())
cert.set_subject(subject)
cert.set_pubkey(req.get_pubkey())
self.extension_manager.setDefaultExtensions(
cert,
subject=cert,
issuer=ca_key_pair['crt'],
crl_url=self.crl_base_url)
cert.sign(ca_key_pair['key'], self.default_digest)
return cert
def _createCertificateRevocationList(self):
""" """
Create CRL from certification revocation_list and return a PEM string content Backup the entire storage to given path, enciphering it using all stored
certificates.
""" """
ca_cert_list = self.getCACertificateList()
revocation_list = self._storage.getRevocationList() crl = x509.load_pem_x509_crl(
now = datetime.utcnow() self.getCertificateRevocationList(),
crl = crypto.CRL() _cryptography_backend,
# XXX - set_nextUpdate() doesn't update Next Update in generated CRL,
# So we have used export() which takes the number of days in param
#
# next_date = now + timedelta(0, 864000) #self.crl_life_time)
# crl.set_lastUpdate(now.strftime("%Y%m%d%H%M%SZ").encode("ascii"))
# crl.set_nextUpdate(next_date.strftime("%Y%m%d%H%M%SZ").encode("ascii"))
num_crl_days = int(round(self.crl_life_time/(24.0*60*60), 0))
if num_crl_days == 0:
# At least one day
num_crl_days = 1
for revocation in revocation_list:
revoked = crypto.Revoked()
revoked.set_rev_date(
revocation.creation_date.strftime("%Y%m%d%H%M%SZ").encode("ascii")
) )
revoked.set_serial(revocation.serial.encode("ascii")) signing_key = os.urandom(32)
revoked.set_reason(None) #b'%s' % revocation.reason) symetric_key = os.urandom(32)
crl.add_revoked(revoked) iv = os.urandom(16)
encryptor = Cipher(
version_number = self._storage.getNextCRLVersionNumber() algorithms.AES(symetric_key),
crl.set_version(version_number) modes.CBC(iv),
# XXX - set how to get the cacert here backend=_cryptography_backend,
cert = self._ca_key_pairs_list[-1]['crt'] ).encryptor()
key = self._ca_key_pairs_list[-1]['key'] authenticator = hmac.HMAC(
signing_key,
#crl.sign(cert, key, self.default_digest) hashes.SHA256(),
dumped_crl = crl.export( backend=_cryptography_backend,
cert,
key,
type=crypto.FILETYPE_PEM,
days=num_crl_days,
digest=self.default_digest)
return self._storage.storeCertificateRevocationList(
crypto.load_crl(crypto.FILETYPE_PEM, dumped_crl),
expiration_date=(now + timedelta(num_crl_days, 0))
) )
HMAC_PAYLOAD_SIZE = 10 * 1024 * 1024
key_list = []
for crt_pem in self._storage.iterCertificates():
try:
crt = utils.load_certificate(crt_pem, ca_cert_list, crl)
except CertificateVerificationError:
continue
public_key = crt.public_key()
key_list.append({
'id': x509.SubjectKeyIdentifier.from_public_key(
public_key,
).digest.encode('hex'),
'cipher': {
'name': 'rsa_oaep_sha1_mgf1_sha1',
},
'key': public_key.encrypt(
signing_key + symetric_key,
OAEP(
mgf=MGF1(algorithm=hashes.SHA1()),
algorithm=hashes.SHA1(),
label=None,
),
).encode('hex'),
})
if not key_list:
# No users yet, backup is meaningless
return False
header = json.dumps({
'cipher': {
'name': 'aes256_cbc_pkcs7_hmac_10M_sha256',
'parameter': iv.encode('hex'),
},
'key_list': key_list,
})
padder = padding.PKCS7(128).padder()
write(_BACKUP_MAGIC)
write(struct.pack('<I', len(header)))
write(header)
def signingIterator():
"""
Iterate over cleartext dump, inserting HMAC between each chunk.
"""
buf = b''
for chunk in self._storage.dumpIterator():
buf += chunk
while len(buf) >= HMAC_PAYLOAD_SIZE:
chunk = buf[:HMAC_PAYLOAD_SIZE]
buf = buf[HMAC_PAYLOAD_SIZE:]
authenticator.update(chunk)
yield chunk
yield authenticator.copy().finalize()
if buf:
authenticator.update(buf)
yield buf
yield authenticator.finalize()
for chunk in signingIterator():
write(encryptor.update(padder.update(chunk)))
write(encryptor.update(padder.finalize()))
write(encryptor.finalize())
return True
@classmethod
def restoreBackup(
cls,
db_class,
db_path,
read,
key_pem,
csr_pem,
db_kw=(),
kw=(),
):
"""
Restore backup, revoke certificate corresponding to private key and sign
its renewal.
"""
magic = read(8)
if magic != _BACKUP_MAGIC:
raise ValueError('Invalid backup magic string')
header_len, = struct.unpack(
'<I',
read(struct.calcsize('<I')),
)
header = json.loads(read(header_len))
if header['cipher']['name'] != 'aes256_cbc_pkcs7_hmac_10M_sha256':
raise ValueError('Unrecognised symetric cipher')
private_key = utils.load_privatekey(key_pem)
key_id = x509.SubjectKeyIdentifier.from_public_key(
private_key.public_key(),
).digest.encode('hex')
symetric_key_list = [
x for x in header['key_list'] if x['id'] == key_id
]
if not symetric_key_list:
raise ValueError(
'Given private key is not a good candidate for restoring this backup',
)
symetric_key_entry, = symetric_key_list
if symetric_key_entry['cipher']['name'] != 'rsa_oaep_sha1_mgf1_sha1':
raise ValueError('Unrecognised asymetric cipher')
both_keys = private_key.decrypt(
symetric_key_entry['key'].decode('hex'),
OAEP(
mgf=MGF1(algorithm=hashes.SHA1()),
algorithm=hashes.SHA1(),
label=None,
),
)
if len(both_keys) != 64:
raise ValueError('Invalid key length')
decryptor = Cipher(
algorithms.AES(both_keys[32:]),
modes.CBC(header['cipher']['parameter'].decode('hex')),
backend=_cryptography_backend,
).decryptor()
unpadder = padding.PKCS7(128).unpadder()
authenticator = hmac.HMAC(
both_keys[:32],
hashes.SHA256(),
backend=_cryptography_backend,
)
HMAC_PAYLOAD_SIZE = 10 * 1024 * 1024
# Each block has its signature
HMAC_SIGNED_SIZE = HMAC_PAYLOAD_SIZE + 32
CHUNK_SIZE = HMAC_SIGNED_SIZE
def restorator():
"""
Iterate over backup payload, decyphering by small chunks.
"""
while True:
chunk = read(CHUNK_SIZE)
if chunk:
yield unpadder.update(decryptor.update(chunk))
else:
yield unpadder.update(decryptor.finalize()) + unpadder.finalize()
break
def verificator():
"""
Iterate over decrypted payload, verifying HMAC on each chunk.
"""
buf = b''
for clear in restorator():
buf += clear
while len(buf) >= HMAC_SIGNED_SIZE:
signed = buf[:HMAC_SIGNED_SIZE]
buf = buf[HMAC_SIGNED_SIZE:]
chunk = signed[:-32]
authenticator.update(chunk)
authenticator.copy().verify(signed[-32:])
yield chunk
if buf:
chunk = buf[:-32]
authenticator.update(chunk)
authenticator.verify(buf[-32:])
yield chunk
db_class.restore(db_path=db_path, restorator=verificator())
# Now that the database is restored, use a CertificateAuthority to
# renew & revoke given private key.
self = cls(storage=db_class(db_path=db_path, **dict(db_kw)), **dict(kw))
# pylint: disable=protected-access
crt_pem = self._storage.getCertificateByKeyIdentifier(key_id)
# pylint: enable=protected-access
new_crt_pem = self.renew(crt_pem, csr_pem)
self.revoke(crt_pem)
return new_crt_pem
...@@ -15,33 +15,862 @@ ...@@ -15,33 +15,862 @@
# #
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with caucase. If not, see <http://www.gnu.org/licenses/>. # 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 argparse
import datetime
import httplib
import json
import os import os
from caucase import app, db import struct
from flask_alchemydumps import AlchemyDumps, AlchemyDumpsCommand import sys
from flask_script import Manager, Command from cryptography import x509
from cryptography.hazmat.backends import default_backend
from . import utils
from . import exceptions
from .client import (
CaucaseError,
CaucaseClient,
HTTPSOnlyCaucaseClient,
updateCAFile,
updateCRLFile,
)
_cryptography_backend = default_backend()
app.config.update( STATUS_ERROR = 1
DEBUG=False, STATUS_WARNING = 2
CSRF_ENABLED=True, STATUS_CALLBACK_ERROR = 3
TESTING=False,
SQLALCHEMY_DATABASE_URI='sqlite:///%sca.db' % os.environ.get('CAUCASE_DIR', '') 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.
def __init__(self, client):
self._client = client
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)
print self._client.putCSR(csr_pem), csr_path
def getCSR(self, csr_id_path_list):
"""
--get-csr
"""
for csr_id, csr_path in csr_id_path_list:
csr_pem = self._client.getCSR(int(csr_id))
with open(csr_path, 'a') as csr_file:
csr_file.write(csr_pem)
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:
crt_pem = self._client.getCRT(crt_id)
except CaucaseError, e:
if e.args[0] != httplib.NOT_FOUND:
raise
try:
self._client.getCSR(crt_id)
except CaucaseError, e:
if e.args[0] != httplib.NOT_FOUND:
raise
print crt_id, 'not found - either csr id has a typo or CSR was rejected'
error = True
else:
print crt_id, 'CSR still pending'
warning = True
else:
print crt_id,
if utils.isCertificateAutoSigned(utils.load_certificate(
crt_pem,
ca_list,
None,
)):
print 'was (originally) automatically approved'
else:
print 'was (originally) manually approved'
if os.path.exists(crt_path):
try:
key_pem = utils.getKey(crt_path)
except ValueError:
print >>sys.stderr, (
'Expected to find exactly one privatekey key in %s, skipping' % (
crt_path,
)
)
error = True
continue
try:
utils.validateCertAndKey(crt_pem, key_pem)
except ValueError:
print >>sys.stderr, (
'Key in %s does not match retrieved certificate, skipping'
)
error = True
continue
with open(crt_path, 'a') as crt_file:
crt_file.write(crt_pem)
return warning, error
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:
print >>sys.stderr, (
'Could not find (exactly) one matching key pair in %s, skipping' % (
[x for x in set((crt_path, key_path)) if x]
)
)
error = True
continue
self._client.revokeCRT(crt, key)
return error
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:
print >>sys.stderr, (
'Could not find (exactly) one matching key pair in %s, skipping' % (
[x for x in set((crt_path, key_path)) if x]
)
)
error = True
continue
try:
old_crt = utils.load_certificate(old_crt_pem, ca_certificate_list, None)
except exceptions.CertificateVerificationError:
print crt_path, (
'was not signed by this CA, revoked or otherwise invalid, skipping'
)
continue
if renewal_deadline < old_crt.not_valid_after:
print crt_path, 'did not reach renew threshold, not renewing'
continue
new_key_pem, new_crt_pem = self._client.renewCRT(
old_crt=old_crt,
old_key=utils.load_privatekey(old_key_pem),
key_len=key_len,
)
if key_path is None:
with open(crt_path, 'w') as crt_file:
crt_file.write(new_key_pem)
crt_file.write(new_crt_pem)
else:
with open(crt_path, 'w') as crt_file, open(key_path, 'w') as key_file:
key_file.write(new_key_pem)
crt_file.write(new_crt_pem)
updated = True
return updated, error
manager = Manager(app) def listCSR(self, mode):
"""
--list-csr
"""
print '-- pending', mode, 'CSRs --'
print '%20s | %s' % (
'csr_id',
'subject preview (fetch csr and check full content !)',
)
for entry in self._client.getCSRList():
csr = utils.load_certificate_request(entry['csr'])
print '%20i | %r' % (
entry['id'],
csr.subject,
)
print '-- end of pending', mode, 'CSRs --'
# init Alchemy Dumps def signCSR(self, csr_id_list):
alchemydumps = AlchemyDumps(app, db) """
manager.add_command('database', AlchemyDumpsCommand) --sign-csr
"""
for csr_id in csr_id_list:
self._client.signCSR(int(csr_id))
@manager.command def signCSRWith(self, csr_id_path_list):
def housekeep():
""" """
Start Storage housekeep method to cleanup garbages --sign-csr-with
""" """
from caucase.storage import Storage for csr_id, csr_path in csr_id_path_list:
storage = Storage(db) self._client.signCSR(
storage.housekeep() int(csr_id),
template_csr=utils.getCertRequest(csr_path),
)
def main(): def rejectCSR(self, csr_id_list):
manager.run() """
--reject-csr
"""
for csr_id in csr_id_list:
self._client.deleteCSR(int(csr_id))
def revokeOtherCRT(self, crt_list):
"""
--revoke-other-crt
"""
error = False
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:
print >>sys.stderr, (
'Could not load a single certificate in %s, skipping' % (
crt_path,
)
)
self._client.revokeCRT(crt_pem)
return error
def revokeSerial(self, serial_list):
"""
--revoke-serial
"""
for serial in serial_list:
self._client.revokeSerial(serial)
def main(argv=None):
"""
Command line caucase client entry point.
"""
parser = argparse.ArgumentParser(description='caucase')
# XXX: currently, it is the server which chooses which digest is used to sign stuff.
# 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',
help='Services CA certificate file location. default: %(default)s',
)
parser.add_argument(
'--user-ca-crt',
default='cau.crt.pem',
metavar='CRT_PATH',
help='Users CA certificate file location. default: %(default)s',
)
parser.add_argument(
'--crl',
default='cas.crl.pem',
metavar='CRL_PATH',
help='Services certificate revocation list location. default: %(default)s',
)
parser.add_argument(
'--user-crl',
default='cau.crl.pem',
metavar='CRL_PATH',
help='Users certificate revocation list location. default: %(default)s',
)
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=[],
metavar=('CSR_ID', 'CRL_PATH'),
help='Retrieve the certificate identified by '
'identifier and store it at given path. '
'If CSR_PATH exists and contains the private key corresponding to '
'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.'
)
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)
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)
):
print >>sys.stderr, (
'A given CSR_ID cannot be in more than one of --sign-csr, '
'--sign-csr-with and --reject-csr'
)
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.
updated |= updateCAFile(cas_url, args.ca_crt)
# --update-user, CA part
if args.update_user or args.mode == MODE_USER:
updated |= updateCAFile(cau_url, args.user_ca_crt)
client = CLICaucaseClient(
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,
),
)
ca_list = [
utils.load_ca_certificate(x)
for x in utils.getCertList({
MODE_SERVICE: args.ca_crt,
MODE_USER: args.user_ca_crt,
}[args.mode])
]
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(
args.threshold,
0,
),
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)
error |= client.revokeOtherCRT(args.revoke_other_crt)
client.revokeSerial(args.revoke_serial)
# show latest CSR list status
if args.list_csr:
client.listCSR(args.mode)
# update our CRL after all revocations we were requested
updated |= updateCRLFile(cas_url, args.crl, [
utils.load_ca_certificate(x)
for x in utils.getCertList(args.ca_crt)
])
# --update-user, CRL part
if args.update_user:
updated |= updateCRLFile(cau_url, args.user_crl, [
utils.load_ca_certificate(x)
for x in utils.getCertList(args.user_ca_crt)
])
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',
)
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'
http_client = CaucaseClient(
ca_url=cas_url,
)
http_ca_pem = http_client.getCA()
https_ca_pem = HTTPSOnlyCaucaseClient(
ca_url=cas_url,
ca_crt_pem_list=[http_ca_pem],
).getCA()
# Retrieve again in case there was a renewal between both calls - we do
# not expect 2 renewals in very short succession.
http2_ca_pem = http_client.getCA()
if https_ca_pem not in (http_ca_pem, http2_ca_pem):
raise ValueError('http and https do not serve the same caucase database')
def monitor(argv=None):
"""
Bootstrap certificate and companion files and keep them up-to-date.
"""
parser = argparse.ArgumentParser(
description='caucase monitor - '
'Bootstrap certificate and companion files and keep them up-to-date',
)
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 '
'to caucase. Will be maintained up-to-date. '
'default: %(default)s',
)
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(
'--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 '
'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 .',
)
parser.add_argument(
'--crt',
required=True,
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. '
'Will be maintained up-to-date.'
)
parser.add_argument(
'--crl',
required=True,
metavar='CRT_PATH',
help='Path of your certificate revocation list for MODE. '
'Will be maintained up-to-date.'
)
args = parser.parse_args(argv)
try:
cas_url = args.ca_url + '/cas'
ca_url = {
MODE_SERVICE: cas_url,
MODE_USER: args.ca_url + '/cau',
}[args.mode]
threshold = datetime.timedelta(args.threshold, 0)
max_sleep = datetime.timedelta(args.max_sleep, 0)
updated = updateCAFile(cas_url, args.cas_ca) and args.cas_ca == args.ca
client = CaucaseClient(
ca_url=ca_url,
ca_crt_pem_list=utils.getCertList(args.cas_ca)
)
if not utils.hasOneCert(args.crt):
print 'Bootstraping...'
csr_pem = utils.getCertRequest(args.csr)
# Quick sanity check before bothering server
utils.load_certificate_request(csr_pem)
csr_id = client.putCSR(csr_pem)
print 'Waiting for signature of', csr_id
while True:
try:
crt_pem = client.getCRT(csr_id)
except CaucaseError, e:
if e.args[0] != httplib.NOT_FOUND:
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.
client.getCSR(csr_id)
# Still here ? Ok, wait a bit and try again.
utils.interruptibleSleep(60)
else:
with open(args.crt, 'a') as crt_file:
crt_file.write(crt_pem)
updated = True
break
print 'Bootstrap done'
next_deadline = datetime.datetime.utcnow()
while True:
print 'Next wake-up at', next_deadline.strftime(
'%Y-%m-%d %H:%M:%S +0000'
)
now = utils.until(next_deadline)
if args.cas_ca != args.ca and updateCAFile(cas_url, args.cas_ca):
client = CaucaseClient(
ca_url=ca_url,
ca_crt_pem_list=utils.getCertList(args.cas_ca)
)
if updateCAFile(ca_url, args.ca):
print 'Got new CA'
updated = True
ca_crt_list = [
utils.load_ca_certificate(x)
for x in utils.getCertList(args.ca)
]
if updateCRLFile(ca_url, args.crl, ca_crt_list):
print 'Got new CRL'
updated = True
crt_pem, key_pem, key_path = utils.getKeyPair(args.crt, args.key)
crt = utils.load_certificate(crt_pem, ca_crt_list, None)
next_deadline = crt.not_valid_after - threshold
if next_deadline <= now:
print 'Renewing', args.crt
new_key_pem, new_crt_pem = client.renewCRT(
old_crt=crt,
old_key=utils.load_privatekey(key_pem),
key_len=args.key_len,
)
if key_path is None:
with open(args.crt, 'w') as crt_file:
crt_file.write(new_key_pem)
crt_file.write(new_crt_pem)
else:
with open(args.crt, 'w') as crt_file, open(key_path, 'w') as key_file:
key_file.write(new_key_pem)
crt_file.write(new_crt_pem)
updated = True
next_deadline = min(
next_deadline,
utils.load_crl(open(args.crl).read(), ca_crt_list).next_update,
now + max_sleep,
)
if updated:
if args.on_renew is not None:
status = os.system(args.on_renew)
if status:
print >>sys.stderr, 'Renewal hook exited with status:', status
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
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.',
)
parser.add_argument(
'--template',
required=True,
type=argparse.FileType('r'),
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)
template = utils.load_certificate_request(utils.getCertRequest(args.template))
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)
orig_umask = os.umask(0177)
try:
with open(args.key, 'w') as key_file:
key_file.write(key_pem)
finally:
os.umask(orig_umask)
with open(args.csr, 'w') as csr_file:
csr_file.write(csr_pem)
def key_id(argv=None):
"""
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.',
)
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)
for key_path in args.private_key:
print key_path, x509.SubjectKeyIdentifier.from_public_key(
utils.load_privatekey(open(key_path).read()).public_key(),
).digest.encode('hex')
for backup_path in args.backup:
print backup_path
with open(backup_path) as backup_file:
magic = backup_file.read(8)
if magic != 'caucase\0':
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']:
print ' ', key_entry['id']
# This file is part of caucase
# Copyright (C) 2017 Nexedi
# 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/>.
import os, errno
import time
import ConfigParser
import logging
import requests
import argparse
import traceback
import pem
import json
import subprocess
import hashlib
from OpenSSL import crypto
from caucase import utils
from datetime import datetime, timedelta
CSR_KEY_FILE = 'csr.key.txt'
RENEW_CSR_KEY_FILE = 'renew_csr.key.txt'
def popenCommunicate(command_list):
subprocess_kw = dict(stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
popen = subprocess.Popen(command_list, **subprocess_kw)
result = popen.communicate()[0]
if popen.returncode is None:
popen.kill()
if popen.returncode != 0:
raise ValueError('Issue during calling %r, result was:\n%s' % (
command_list, result))
return result
def parseArguments():
"""
Parse arguments for Certificate Authority Request.
"""
parser = argparse.ArgumentParser()
parser.add_argument('--ca-url',
required=True,
help='Certificate Authority URL')
parser.add_argument('-c', '--ca-crt-file',
default='ca.crt.pem',
help='Path for CA Cert file. default: %(default)s')
parser.add_argument('-x', '--crt-file',
default='crt.pem',
help='Path for Certificate file. default: %(default)s')
parser.add_argument('-k', '--key-file',
default='key.pem',
help='Path of key file. default: %(default)s')
parser.add_argument('-s', '--csr-file',
default='csr.pem',
help='Path where to store csr file. default: %(default)s')
parser.add_argument('-r', '--crl-file',
default='crl.pem',
help='Path where to store crl file. default: %(default)s')
parser.add_argument('--digest',
default="sha256",
help='Digest used to sign data. default: %(default)s')
parser.add_argument('--cn',
help='Common name to use when request new certificate.')
parser.add_argument('--threshold',
help='The minimum remaining certificate validity time in' \
' seconds after which renew of certificate can be triggered.',
type=int)
parser.add_argument('--on-renew',
help='Path of an executable file to call after certificate'\
' renewal.')
parser.add_argument('--on-crl-update',
help='Path of an executable file to call after CRL '\
'file update.')
parser.add_argument('--no-check-certificate',
action='store_false', default=True, dest='verify_certificate',
help='When connecting to CA on HTTPS, disable certificate verification.')
group = parser.add_mutually_exclusive_group()
group.add_argument('--request', action='store_true',
help='Request a new Certificate.')
group.add_argument('--revoke', action='store_true',
help='Revoke existing certificate.')
group.add_argument('--renew', action='store_true',
help='Renew current certificate and and replace with existing files.')
group.add_argument('--update-crl', action='store_true',
help='Download and store the new CRL file if there was a new revocation.')
return parser
def requestCertificate(ca_request, config):
# download or update ca crt file
ca_request.getCACertificateChain()
if os.path.exists(config.crt_file):
return
if not os.path.exists(config.csr_file):
csr = ca_request.generateCertificateRequest(config.key_file,
cn=config.cn, csr_file=config.csr_file)
else:
csr = open(config.csr_file).read()
ca_request.signCertificate(csr)
def revokeCertificate(ca_revoke, config):
os.close(os.open(config.key_file, os.O_RDONLY))
os.close(os.open(config.crt_file, os.O_RDONLY))
# download or update ca crt file
ca_revoke.getCACertificateChain()
ca_revoke.revokeCertificate()
def renewCertificate(ca_renew, config, backup_dir):
os.close(os.open(config.key_file, os.O_RDONLY))
os.close(os.open(config.crt_file, os.O_RDONLY))
# download or update ca crt file
ca_renew.getCACertificateChain()
ca_renew.renewCertificate(config.csr_file,
backup_dir,
config.threshold,
after_script=config.on_renew)
def main():
parser = parseArguments()
config = parser.parse_args()
base_dir = os.path.dirname(config.crt_file)
os.chdir(os.path.abspath(base_dir))
if not config.ca_url:
parser.error('`ca-url` parameter is required. Use --ca-url URL')
parser.print_help()
exit(1)
ca_client = CertificateAuthorityRequest(config.key_file, config.crt_file,
config.ca_crt_file, config.ca_url, digest=config.digest,
verify_certificate=config.verify_certificate)
if config.request:
if not config.cn:
parser.error('Option --cn is required for request.')
parser.print_help()
exit(1)
requestCertificate(ca_client, config)
elif config.revoke:
revokeCertificate(ca_client, config)
elif config.renew:
if not config.threshold:
parser.error('`threshold` parameter is required with renew. Use --threshold VALUE')
parser.print_help()
exit(1)
backup_dir = os.path.join('.',
'backup-%s' % datetime.now().strftime('%Y-%m-%d-%H%M%S'))
# cleanup
if os.path.exists(CSR_KEY_FILE):
os.unlink(CSR_KEY_FILE)
if os.path.exists(config.csr_file):
os.unlink(config.csr_file)
renewCertificate(ca_client, config, backup_dir)
elif config.update_crl:
ca_client.updateCertificateRevocationList(config.crl_file,
after_script=config.on_crl_update)
else:
parser.error('Please set one of options: --request | --revoke | --renew | --update-crl.')
parser.print_help()
exit(1)
class CertificateAuthorityRequest(object):
def __init__(self, key, certificate, cacertificate, ca_url,
max_retry=10, digest="sha256", sleep_time=5,
verify_certificate=False, logger=None):
self.key = key
self.certificate = certificate
self.cacertificate = cacertificate
self.ca_url = ca_url
self.logger = logger
# maximum retry number of post/put request
self.max_retry = max_retry
# time to sleep before retry failed request
self.sleep_time = sleep_time
self.digest = digest
self.extension_manager = utils.X509Extension()
self.ca_certificate_list = []
self.verify_certificate = verify_certificate
while self.ca_url.endswith('/'):
# remove all / at end or ca_url
self.ca_url = self.ca_url[:-1]
if os.path.exists(self.cacertificate):
self.ca_certificate_list = [
crypto.load_certificate(crypto.FILETYPE_PEM, x._pem_bytes) for x in
pem.parse_file(self.cacertificate)
]
if self.logger is None:
self.logger = logging.getLogger('Caucase Client')
self.logger.setLevel(logging.DEBUG)
handler = logging.StreamHandler()
handler.setLevel(logging.DEBUG)
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
handler.setFormatter(formatter)
self.logger.addHandler(handler)
self.generatePrivatekey(self.key)
def _request(self, method, url, data=None):
try:
req = getattr(requests, method)
kw = {}
if data:
kw['data'] = data
kw['verify'] = self.verify_certificate
return req(url, **kw)
except requests.ConnectionError, e:
self.logger.error("Got ConnectionError while sending request to CA. Url is %s\n%s" % (
url, str(e)))
return None
def _checkCertEquals(self, first_cert, second_cert):
"""
Say if two certificate PEM object are the same
XXX - more checks should be done ?
"""
return first_cert.set_subject().CN == second_cert.get_subject().CN and \
first_cert.get_serial_number() == second_cert.get_serial_number()
def _writeNewFile(self, file_path, content, mode=0644):
fd = os.open(file_path,
os.O_CREAT | os.O_EXCL | os.O_WRONLY | os.O_TRUNC, mode)
try:
os.write(fd, content)
finally:
os.close(fd)
def _sendCertificateSigningRequest(self, csr_string):
request_url = '%s/csr' % self.ca_url
data = {'csr': csr_string}
retry = 0
response = self._request('put', request_url, data=data)
while (not response or response.status_code != 201) and retry < self.max_retry:
self.logger.error("%s: Failed to sent CSR. \n%s" % (
response.status_code, response.text))
self.logger.info("will retry in %s seconds..." % self.sleep_time)
time.sleep(self.sleep_time)
retry += 1
response = self._request('put', request_url, data=data)
if not response or response.status_code != 201:
raise Exception("ERROR: failed to send CSR after %s retry." % retry)
self.logger.info("CSR succefully sent.")
# Get csr Location from request header: http://xxx.com/csr/key
self.logger.debug("CSR location is: %s" % response.headers['Location'])
csr_key = response.headers['Location'].split('/')[-1]
with open(CSR_KEY_FILE, 'w') as fkey:
fkey.write(csr_key)
return csr_key
def _sendCertificateRenewal(self, cert, csr):
payload = dict(renew_csr=csr, crt=cert)
pkey = open(self.key).read()
wrapped = utils.wrap(payload, pkey, [self.digest])
request_url = '%s/crt/renew' % self.ca_url
data = {'payload': json.dumps(wrapped)}
self.logger.info("Sending Certificate Renewal request...")
response = self._request('put', request_url, data=data)
break_code = [201, 404, 500, 404]
retry = 1
while response is None or response.status_code not in break_code:
self.logger.error("%s: Failed to send renewal request. \n%s" % (
response.status_code, response.text))
self.logger.info("will retry in %s seconds..." % self.sleep_time)
time.sleep(self.sleep_time)
response = self._request('put', request_url, data=data)
retry += 1
if retry > self.max_retry:
break
if not response or response.status_code != 201:
raise Exception("ERROR: failed to send certificate renewal request "\
"after %s retry.\n%s" % (
retry, response.text))
csr_key = response.headers['Location'].split('/')[-1]
with open(RENEW_CSR_KEY_FILE, 'w') as fkey:
fkey.write(csr_key)
return csr_key
def _getSignedCertificate(self, crt_id):
reply_url = '%s/crt/%s' % (self.ca_url, crt_id)
response = self._request('get', reply_url)
while not response or response.status_code != 200:
time.sleep(self.sleep_time)
response = self._request('get', reply_url)
return response.text
def generateCertificateRequest(self, key_file, cn,
country='', state='', locality='', email='', organization='',
organization_unit='', csr_file=None):
"""
Generate certificate Signature request.
Parameter `cn` is mandatory
"""
with open(key_file) as fkey:
key = crypto.load_privatekey(crypto.FILETYPE_PEM, fkey.read())
req = crypto.X509Req()
subject = req.get_subject()
subject.CN = cn
if country:
subject.C = country
if state:
subject.ST = state
if locality:
subject.L = locality
if organization:
subject.O = organization
if organization_unit:
subject.OU = organization_unit
if email:
subject.emailAddress = email
req.set_pubkey(key)
self.extension_manager.setDefaultCsrExtensions(req)
req.sign(key, self.digest)
csr = crypto.dump_certificate_request(crypto.FILETYPE_PEM, req)
if csr_file is not None:
with open(csr_file, 'w') as req_file:
req_file.write(csr)
os.chmod(csr_file, 0640)
return csr
def generatePrivatekey(self, output_file, size=2048):
"""
Generate private key into `output_file`
"""
try:
key_fd = os.open(output_file,
os.O_CREAT|os.O_WRONLY|os.O_EXCL|os.O_TRUNC,
0600)
except OSError, e:
if e.errno != errno.EEXIST:
raise
else:
key = crypto.PKey()
key.generate_key(crypto.TYPE_RSA, size)
os.write(key_fd, crypto.dump_privatekey(crypto.FILETYPE_PEM, key))
os.close(key_fd)
def checkCertificateValidity(self, cert):
"""
validate the certificate PEM string with the CA Certificate and private key
"""
cert_pem = crypto.load_certificate(crypto.FILETYPE_PEM, cert)
pkey = open(self.key).read()
key_pem = crypto.load_privatekey(crypto.FILETYPE_PEM, pkey)
return utils.checkCertificateValidity(
self.ca_certificate_list,
cert_pem,
key_pem)
def isCertExpirationDateValid(self, x509, threshold):
"""
Return True if remaning certificate valid time is second is lower than
the threshold value
"""
expiration_date = datetime.strptime(
x509.get_notAfter(), '%Y%m%d%H%M%SZ'
)
now_date = datetime.utcnow()
limit_date = now_date + timedelta(0, threshold)
expire_in = expiration_date - limit_date
if expire_in.days > 0.0:
return True
return False
def updateCACertificateChain(self):
"""
Request to CA all valid certificates an update in to cacertificate file
@note: if the CA has more that one valid certificate, the cacertificate
file will be updated contain concatenated cert them like:
CA_1
CA_2
...
CA_N
"""
ca_cert_url = '%s/crt/ca.crt.json' % self.ca_url
self.logger.info("Updating CA certificate file from %s" % ca_cert_url)
cert_list = response_json = []
cert_list_chain = ""
response = self._request('get', ca_cert_url)
while not response or response.status_code != 200:
# sleep a bit then try again until ca cert is ready
time.sleep(10)
response = self._request('get', ca_cert_url)
response_json = json.loads(response.text)
if len(response_json) > 0:
iter_ca_cert = iter(response_json)
is_valid = False
payload = utils.unwrap(iter_ca_cert.next(), lambda x: x['old'], [self.digest])
# check that old certificate is known
old_x509 = crypto.load_certificate(crypto.FILETYPE_PEM, payload['old'])
for x509 in self.ca_certificate_list:
if self._checkCertEquals(x509, old_x509):
is_valid = True
if not is_valid:
# no local certificate matches
raise CertificateVerificationError("Updated CA Certificate chain could " \
"not be validated using local CA Certificate at %r. \nYou can " \
"try removing your local ca file if it was not updated for more " \
"that a year." % self.cacertificate)
# if not old_x509.has_expired():
# XXX - TODO: check if expired old_x509 can break certificate validation
cert_list.append(old_x509)
cert_list.append(
crypto.load_certificate(crypto.FILETYPE_PEM, payload['new'])
)
cert_list_chain = "%s\n%s" % (payload['old'], payload['new'])
for next_item in iter_ca_cert:
payload = utils.unwrap(next_item, lambda x: x['old'], [self.digest])
old_x509 = crypto.load_certificate(crypto.FILETYPE_PEM, payload['old'])
if self._checkCertEquals(cert_list[-1], old_x509):
cert_list.append(
crypto.load_certificate(crypto.FILETYPE_PEM, payload['new'])
)
cert_list_chain += "\n%s" % payload['new']
else:
raise CertificateVerificationError("Get CA Certificate chain " \
"retourned %s \n\nbut validation of data failed" % response_json)
# dump into file
if not cert_list:
# Nothing to do...
return
self.ca_certificate_list = cert_list
fd = os.open(self.cacertificate, os.O_CREAT|os.O_WRONLY, 0640)
try:
os.write(fd, cert_list_chain)
finally:
os.close(fd)
def getCACertificateChain(self):
"""
Get CA certificate file.
If it's the first download, get the latest valid certificate at ca.crt.pem
else, update current cacertificate with list of valid ca certificat chain
"""
# If cert file exists exist
if os.path.exists(self.cacertificate) and os.stat(self.cacertificate).st_size > 0:
# Get all valids CA certificate
return self.updateCACertificateChain()
ca_cert_url = '%s/crt/ca.crt.pem' % self.ca_url
self.logger.info("getting CA certificate file %s" % ca_cert_url)
response = None
while not response or response.status_code != 200:
response = self._request('get', ca_cert_url)
if response is not None:
try:
x509 = crypto.load_certificate(crypto.FILETYPE_PEM, response.text)
except crypto.Error, e:
# XXX - we got a bad certificate, break here ?
traceback.print_exc()
response = None
else:
self.ca_certificate_list = [x509]
break
# sleep a bit then try again until ca cert is ready
time.sleep(10)
fd = os.open(self.cacertificate,
os.O_CREAT | os.O_EXCL | os.O_WRONLY | os.O_TRUNC, 0640)
try:
os.write(fd, response.text)
finally:
os.close(fd)
def signCertificate(self, csr):
"""
Send certificate signature request and wait until the certificate is
signed.
csr parameter is string in PEM format
"""
if os.path.exists(self.certificate) and os.stat(self.certificate).st_size > 0:
# exit because the certificate exists
return
csr_key = ""
self.logger.info("Request signed certificate from CA...")
if os.path.exists(CSR_KEY_FILE):
with open(CSR_KEY_FILE) as fkey:
csr_key = fkey.read()
if csr_key:
self.logger.info("Csr was already sent to CA, using csr : %s" % csr_key)
else:
csr_key = self._sendCertificateSigningRequest(csr)
self.logger.info("Waiting for signed certificate...")
# csr is xxx.csr.pem so cert is xxx.cert.pem
certificate = self._getSignedCertificate('%s.crt.pem' % csr_key[:-8])
self.logger.info("Validating signed certificate...")
if not self.checkCertificateValidity(certificate):
# certificate verification failed, should raise ?
self.logger.warn("Certificate validation failed.\n" \
"Please double check the signed certificate before use. Also consider" \
"revoke it and request a new signed certificate.")
fd = os.open(self.certificate,
os.O_CREAT | os.O_EXCL | os.O_WRONLY | os.O_TRUNC, 0644)
try:
os.write(fd, certificate)
finally:
os.close(fd)
self.logger.info("Certificate correctly saved at %s." % self.certificate)
def revokeCertificate(self, message=""):
"""
Revoke the current certificate on CA.
"""
retry = 1
pkey = open(self.key).read()
cert = open(self.certificate).read()
cert_pem = crypto.load_certificate(crypto.FILETYPE_PEM, cert)
payload = dict(
reason=message,
revoke_crt=cert)
wrapped = utils.wrap(payload, pkey, [self.digest])
request_url = '%s/crt/revoke' % self.ca_url
data = {'payload': json.dumps(wrapped)}
self.logger.info("Sending Certificate revocation request of CN: %s." % (
cert_pem.get_subject().CN))
response = self._request('put', request_url, data=data)
break_code = [201, 404, 500, 404]
while response is None or response.status_code not in break_code:
self.logger.error("%s: Failed to send revoke request. \n%s" % (
response.status_code, response.text))
self.logger.info("will retry in %s seconds..." % self.sleep_time)
time.sleep(self.sleep_time)
response = self._request('put', request_url, data=data)
retry += 1
if retry > self.max_retry:
break
if not response or response.status_code != 201:
raise Exception("ERROR: failed to put revoke certificate after %s retry. Exiting..." % retry)
self.logger.info("Certificate %s was successfully revoked." % (
self.certificate))
def renewCertificate(self, csr_file, backup_dir, threshold, renew_key=True,
after_script=''):
"""
Renew the current certificate. Regenerate private key if renew_key is `True`
"""
new_key_path = '%s.renew' % self.key
new_cert_path = '%s.renew' % self.certificate
key_file = self.key
cert = open(self.certificate).read()
cert_pem = crypto.load_certificate(crypto.FILETYPE_PEM, cert)
csr_key = ""
if self.isCertExpirationDateValid(cert_pem, threshold):
self.logger.info("Nothing to do, no need to renew the certificate.")
return
try:
if renew_key:
self.generatePrivatekey(new_key_path)
key_file = new_key_path
if os.path.exists(RENEW_CSR_KEY_FILE):
csr_key = open(RENEW_CSR_KEY_FILE).read()
if not csr_key:
if not os.path.exists(csr_file):
csr = self.generateCertificateRequest(key_file,
cn=cert_pem.get_subject().CN,
csr_file=csr_file)
else:
csr = open(csr_file).read()
csr_key = self._sendCertificateRenewal(cert, csr)
self.logger.info("Waiting for signed certificate...")
new_cert = self._getSignedCertificate('%s.crt.pem' % csr_key[:-8])
if not os.path.exists(backup_dir):
os.mkdir(backup_dir)
self._writeNewFile(new_cert_path, new_cert)
# change location of files
if renew_key:
os.rename(self.key,
os.path.join(backup_dir, os.path.basename(self.key)))
os.rename(new_key_path, self.key)
self.logger.info("Private correctly renewed at %s." % self.key)
os.rename(self.certificate,
os.path.join(backup_dir, os.path.basename(self.certificate)))
os.rename(new_cert_path, self.certificate)
self.logger.info("Validating signed certificate...")
if not self.checkCertificateValidity(new_cert):
# certificate verification failed, should raise ?
self.logger.warn("Certificate validation failed.\n" \
"Please double check the signed certificate before use. Also consider" \
"revoke it and request a new signed certificate.")
else:
self.logger.info("Certificate correctly renewed at %s." % self.certificate)
except:
raise
else:
for path in [csr_file, RENEW_CSR_KEY_FILE]:
if os.path.exists(path):
os.unlink(path)
if after_script:
output = popenCommunicate([os.path.realpath(after_script)])
self.logger.info("Successfully executed script '%s' with output:\n%s" % (
after_script, output))
finally:
for path in [new_cert_path, new_key_path]:
if os.path.exists(path):
os.unlink(path)
def updateCertificateRevocationList(self, crl_file, after_script=''):
"""
Download or update crl. If the crl_file exists, it will be updated if
the new CRL has changed.
"""
crl_url = '%s/crl' % self.ca_url
self.logger.info("Downloading crl file from %s ..." % crl_url)
response = self._request('get', crl_url)
retry = 1
while not response or response.status_code != 200:
time.sleep(self.sleep_time)
response = self._request('get', crl_url)
retry += 1
if retry > self.max_retry:
break
if not response or response.status_code != 200:
raise Exception("ERROR: failed to get crl file after %s retry. Exiting..." % retry)
crl_string = response.text
# load crl string so we are sure that it is a valid crl string
crl = crypto.load_crl(crypto.FILETYPE_PEM, crl_string)
# Dumped string contain only the CRL without extra info
crl_string = crypto.dump_crl(crypto.FILETYPE_PEM, crl)
update_crl = False
if os.path.exists(crl_file):
with open(crl_file) as fcrl:
old_checksum = hashlib.md5(fcrl.read()).hexdigest()
checksum = hashlib.md5(crl_string).hexdigest()
if checksum != old_checksum:
update_crl = True
else:
update_crl = True
if update_crl:
fd = os.open(crl_file, os.O_CREAT | os.O_WRONLY | os.O_TRUNC, 0644)
try:
os.write(fd, crl_string)
finally:
os.close(fd)
self.logger.info("New CRL file was saved in %s ..." % crl_file)
if after_script:
output = popenCommunicate([os.path.realpath(after_script)])
self.logger.info("Successfully executed script '%s' with output:\n%s" % (
after_script, output))
else:
self.logger.info("crl file don't need to be updated.")
# This file is part of caucase
# Copyright (C) 2017 Nexedi
# 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
__all__ = (
'CaucaseError',
'CaucaseClient',
'HTTPSOnlyCaucaseClient',
'updateCAFile',
'updateCRLFile',
)
_cryptography_backend = default_backend()
class CaucaseError(Exception):
"""
Raised when server responds with an HTTP error status.
"""
pass
def updateCAFile(url, ca_crt_path):
"""
Bootstrap anf maintain a CA file up-to-date.
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.
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 = CaucaseClient(
ca_url=url,
).getCA()
with open(ca_crt_path, 'w') as ca_crt_file:
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(
CaucaseClient(
ca_url=url,
ca_crt_pem_list=ca_pem_list,
).getNewCAList(),
)
if ca_pem_list != loaded_ca_pem_list:
data = ''.join(ca_pem_list)
with open(ca_crt_path, 'w') as ca_crt_file:
ca_crt_file.write(data)
updated = True
return updated
def updateCRLFile(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):
my_crl = utils.load_crl(open(crl_path).read(), ca_list)
else:
my_crl = None
latest_crl_pem = CaucaseClient(
ca_url=url,
).getCRL()
latest_crl = utils.load_crl(latest_crl_pem, ca_list)
if latest_crl != my_crl:
with open(crl_path, 'w') as crl_file:
crl_file.write(latest_crl_pem)
return True
return False
class CaucaseClient(object):
"""
Caucase HTTP(S) client.
Expose caucase REST API as pythonic methods.
"""
def __init__(self, ca_url, ca_crt_pem_list=None, user_key=None):
# XXX: set timeout to HTTP connections ?
http_url = urlparse(ca_url)
port = http_url.port or 80
self._http_connection = httplib.HTTPConnection(
http_url.hostname,
port,
#timeout=,
)
self._ca_crt_pem_list = ca_crt_pem_list
self._path = http_url.path
if ca_crt_pem_list:
ssl_context = ssl.create_default_context(
# unicode object needed as we use PEM, otherwise create_default_context
# expects DER.
cadata=''.join(ca_crt_pem_list).decode('ascii'),
)
if user_key:
ssl_context.load_cert_chain(user_key)
self._https_connection = httplib.HTTPSConnection(
http_url.hostname,
443 if port == 80 else port + 1,
#timeout=,
context=ssl_context,
)
def _request(self, connection, method, url, body=None, headers=None):
path = self._path + url
headers = headers or {}
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)
def getCRL(self):
"""
[ANONYMOUS] Retrieve latest CRL.
"""
return self._http('GET', '/crl')
def getCSR(self, csr_id):
"""
[ANONYMOUS] Retrieve an CSR by its identifier.
"""
return self._http('GET', '/csr/%i' % (csr_id, ))
def getCSRList(self):
"""
[AUTHENTICATED] Retrieve all pending CSRs.
"""
return [
{
y.encode('ascii'): z.encode('ascii') if isinstance(z, unicode) else z
for y, z in x.iteritems()
}
for x in json.loads(self._https('GET', '/csr'))
]
def putCSR(self, csr):
"""
[ANONYMOUS] Store a CSR and return its identifier.
"""
return int(self._http('PUT', '/csr', csr, {
'Content-Type': 'application/pkcs10',
}))
def deleteCSR(self, csr_id):
"""
[AUTHENTICATED] Reject a pending CSR.
"""
self._https('DELETE', '/csr/%i' % (csr_id, ))
def _getCRT(self, crt_id):
return self._http('GET', '/crt' + crt_id)
def getCRT(self, csr_id):
"""
[ANONYMOUS] Retrieve CRT by its identifier (same as corresponding CRL
identifier).
"""
return self._getCRT('/%i' % (csr_id, ))
def getCA(self):
"""
[ANONYMOUS] Retrieve current CA certificate.
"""
return self._getCRT('/ca.crt.pem')
def getNewCAList(self):
"""
[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 = []
for entry in json.loads(self._getCRT('/ca.crt.json')):
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(
payload['old_pem'].encode('ascii'),
) == trust_anchor
if found:
if utils.load_ca_certificate(
payload['old_pem'].encode('ascii'),
) != previous_ca:
raise ValueError('CA signature chain broken')
new_pem = payload['new_pem'].encode('ascii')
result.append(new_pem)
previous_ca = utils.load_ca_certificate(new_pem)
return result
def renewCRT(self, old_crt, old_key, key_len):
"""
[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(
{
'crt_pem': utils.dump_certificate(old_crt),
'renew_csr_pem': utils.dump_certificate_request(
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,
),
),
},
old_key,
utils.DEFAULT_DIGEST,
),
),
{'Content-Type': 'application/json'},
),
)
def revokeCRT(self, crt, key=None):
"""
Revoke certificate.
[ANONYMOUS] if key is provided.
[AUTHENTICATED] if key is missing.
"""
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'},
)
def signCSR(self, csr_id, template_csr=''):
"""
[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
...@@ -15,6 +15,9 @@ ...@@ -15,6 +15,9 @@
# #
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with caucase. If not, see <http://www.gnu.org/licenses/>. # along with caucase. If not, see <http://www.gnu.org/licenses/>.
"""
Caucase - Certificate Authority for Users, Certificate Authority for SErvices
"""
class CertificateAuthorityException(Exception): class CertificateAuthorityException(Exception):
"""Base exception""" """Base exception"""
...@@ -29,23 +32,9 @@ class NotFound(CertificateAuthorityException): ...@@ -29,23 +32,9 @@ class NotFound(CertificateAuthorityException):
pass pass
class Found(CertificateAuthorityException): class Found(CertificateAuthorityException):
"""Requested ID is already in use""" """Resource to create already exists"""
class BadSignature(CertificateAuthorityException):
"""Non-x509 signature check failed"""
class BadCertificateSigningRequest(CertificateAuthorityException):
"""CSR content doesn't contain all required elements"""
pass
class BadCertificate(CertificateAuthorityException):
"""Certificate is not a valid PEM content"""
pass pass
class CertificateVerificationError(CertificateAuthorityException): class CertificateVerificationError(CertificateAuthorityException):
"""Certificate is not valid, it was not signed by CA""" """Certificate is not valid, it was not signed by CA"""
pass pass
class ExpiredCertificate(CertificateAuthorityException):
"""Certificate has expired and could not be used"""
pass
\ No newline at end of file
# This file is part of caucase
# Copyright (C) 2017 Nexedi
# 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 argparse
import datetime
import glob
import os
import signal
import socket
from SocketServer import ThreadingMixIn
import ssl
import sys
import tempfile
from threading import Thread
from urlparse import urlparse
from wsgiref.simple_server import make_server, WSGIServer, WSGIRequestHandler
from cryptography import x509
from cryptography.hazmat.backends import default_backend
from . import utils
from .wsgi import Application
from .ca import CertificateAuthority, UserCertificateAuthority
from .storage import SQLite3Storage
_cryptography_backend = default_backend()
BACKUP_SUFFIX = '.sql.caucased'
class ThreadingWSGIServer(ThreadingMixIn, WSGIServer):
"""
Threading WSGI server
"""
daemon_threads = True
def __init__(self, server_address, *args, **kw):
self.address_family, _, _, _, _ = socket.getaddrinfo(*server_address)[0]
assert self.address_family in (socket.AF_INET, socket.AF_INET6), self.address_family
WSGIServer.__init__(self, server_address, *args, **kw)
class CaucaseWSGIRequestHandler(WSGIRequestHandler):
"""
Make WSGIRequestHandler logging more apache-like.
"""
def log_date_time_string(self):
"""
Apache-style date format.
Compared to python's default (from BaseHTTPServer):
- ":" between day and time
- "+NNNN" timezone is displayed
- ...but, because of how impractical it is in python to get system current
timezone (including DST considerations), time it always logged in GMT
"""
now = datetime.datetime.utcnow()
return now.strftime('%d/' + self.monthname[now.month] + '/%Y:%H:%M:%S +0000')
class CaucaseSSLWSGIRequestHandler(CaucaseWSGIRequestHandler):
"""
Add SSL_CLIENT_CERT to environ when client has sent a certificate.
"""
ssl_client_cert_serial = '-'
def get_environ(self):
environ = WSGIRequestHandler.get_environ(self)
client_cert_der = self.request.getpeercert(binary_form=True)
if client_cert_der is not None:
cert = x509.load_der_x509_certificate(
client_cert_der,
_cryptography_backend,
)
self.ssl_client_cert_serial = str(cert.serial_number)
environ['SSL_CLIENT_CERT'] = utils.dump_certificate(cert)
return environ
# pylint: disable=redefined-builtin
def log_message(self, format, *args):
# Note: compared to BaseHTTPHandler, logs the client certificate serial as
# user name.
sys.stderr.write(
"%s - %s [%s] %s\n" % (
self.client_address[0],
self.ssl_client_cert_serial,
self.log_date_time_string(),
format % args,
)
)
# pylint: enable=redefined-builtin
def startServerThread(server):
"""
Create and start a "serve_forever" thread, and return it.
"""
server_thread = Thread(target=server.serve_forever)
server_thread.daemon = True
server_thread.start()
def updateSSLContext(
https,
key_len,
threshold,
server_key_path,
hostname,
cau,
cas,
wrap=False,
):
"""
Build a new SSLContext with updated CA certificates, CRL and server key pair,
apply it to <https>.socket and return the datetime of next update.
"""
ssl_context = ssl.create_default_context(
purpose=ssl.Purpose.CLIENT_AUTH,
)
# SSL is used for client authentication, and is only required for very few
# users on any given caucased. So make ssl_context even stricter than python
# does.
# No TLSv1 and TLSv1.1, leaving (currently) only TLSv1.2
ssl_context.options |= ssl.OP_NO_TLSv1 | ssl.OP_NO_TLSv1_1
# If a client wishes to use https for unauthenticated operations, that's
# fine too.
ssl_context.verify_mode = ssl.CERT_OPTIONAL
# Note: it does not seem possible to get python's openssl context to check
# certificate revocation:
# - calling load_verify_locations(cadata=<crl data>) or
# load_verify_locations(cadata=<crl data> + <ca crt data>) raises
# - calling load_verify_locations(cadata=<ca crt data> + <crl data>) fails to
# validate CA completely
# Anyway, wsgi application level is supposed (and automatically tested to)
# verify revocations too, so this should not be a big issue... Still,
# implementation cross-check would have been nice.
#ssl_context.verify_flags = ssl.VERIFY_CRL_CHECK_LEAF
ssl_context.load_verify_locations(
cadata=cau.getCACertificate().decode('ascii'),
)
cas_certificate_list = cas.getCACertificateList()
threshold_delta = datetime.timedelta(threshold, 0)
if os.path.exists(server_key_path):
old_crt_pem = utils.getCert(server_key_path)
old_crt = utils.load_certificate(old_crt_pem, cas_certificate_list, None)
if old_crt.not_valid_after - threshold_delta < datetime.datetime.utcnow():
new_key = utils.generatePrivateKey(key_len)
new_key_pem = utils.dump_privatekey(new_key)
new_crt_pem = cas.renew(
crt_pem=old_crt_pem,
csr_pem=utils.dump_certificate_request(
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,
),
),
)
with open(server_key_path, 'w') as crt_file:
crt_file.write(new_key_pem)
crt_file.write(new_crt_pem)
else:
new_key = utils.generatePrivateKey(key_len)
csr_id = cas.appendCertificateSigningRequest(
csr_pem=utils.dump_certificate_request(
x509.CertificateSigningRequestBuilder(
).subject_name(
x509.Name([
x509.NameAttribute(
oid=x509.oid.NameOID.COMMON_NAME,
value=hostname.decode('ascii'),
),
]),
).add_extension(
x509.KeyUsage(
# pylint: disable=bad-whitespace
digital_signature =True,
content_commitment=False,
key_encipherment =True,
data_encipherment =False,
key_agreement =False,
key_cert_sign =False,
crl_sign =False,
encipher_only =False,
decipher_only =False,
# pylint: enable=bad-whitespace
),
critical=True,
).sign(
private_key=new_key,
algorithm=utils.DEFAULT_DIGEST_CLASS(),
backend=_cryptography_backend,
),
),
override_limits=True,
)
cas.createCertificate(csr_id)
new_crt_pem = cas.getCertificate(csr_id)
new_key_pem = utils.dump_privatekey(new_key)
old_mask = os.umask(077)
try:
with open(server_key_path, 'w') as crt_file:
crt_file.write(new_key_pem)
crt_file.write(new_crt_pem)
finally:
os.umask(old_mask)
ssl_context.load_cert_chain(server_key_path)
if wrap:
https.socket = ssl_context.wrap_socket(
sock=https.socket,
server_side=True,
)
else:
https.socket.context = ssl_context
return utils.load_certificate(
utils.getCert(server_key_path),
cas_certificate_list,
None,
).not_valid_after - threshold_delta
def main(argv=None):
"""
Caucase stand-alone http server.
"""
parser = argparse.ArgumentParser(description='caucased')
parser.add_argument(
'--db',
default='caucase.sqlite',
help='Path to the SQLite database. default: %(default)s',
)
parser.add_argument(
'--server-key',
default='server.key.pem',
metavar='KEY_PATH',
help='Path to the ssl key pair to use for https socket. '
'default: %(default)s',
)
parser.add_argument(
'--netloc',
required=True,
help='<host>[:<port>] of HTTP socket. '
'HTTPS socket netloc will be deduced following caucase rules: if port is '
'80 or not provided, https port will be 443, else it will be port + 1. '
'If not provided, http port will be picked among available ports and '
'https port will be the next port. Also, signed certificates will not '
'contain a CRL distribution point URL. If https port is not available, '
'this program will exit with an aerror status. '
'Note on encoding: only ascii is currently supported. Non-ascii may be '
'provided idna-encoded.',
)
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',
)
service_group = parser.add_argument_group(
'CAS options: normal certificates, which are not given any privilege on '
'caucased',
)
user_group = parser.add_argument_group(
'CAU options: special certificates, which are allowed to sign other '
'certificates and can decrypt backups',
)
service_group.add_argument(
'--service-crt-validity',
default=3 * 31,
type=float,
metavar='DAYS',
help='Number of days an issued certificate is valid for. '
'default: %(default)s',
)
user_group.add_argument(
'--user-crt-validity',
default=3 * 31,
type=float,
metavar='DAYS',
help='Number of days an issued certificate is valid for. '
'default: %(default)s',
)
service_group.add_argument(
'--service-max-csr',
default=50,
type=int,
help='Maximum number of pending CSR. Further CSR get refused until '
'an existing ones gets signed or rejected. default: %(default)s',
)
user_group.add_argument(
'--user-max-csr',
default=50,
type=int,
help='Maximum number of pending CSR. Further CSR get refused until '
'an existing ones gets signed or rejected. default: %(default)s',
)
service_group.add_argument(
'--service-auto-approve-count',
default=0,
type=int,
metavar='COUNT',
help='Number service certificates which should be automatically signed on '
'submission, excluding the one needed to serve caucase. '
'default: %(default)s'
)
user_group.add_argument(
'--user-auto-approve-count',
default=1,
type=int,
metavar='COUNT',
help='Number of user certificates which should be automatically signed on '
'submission. default: %(default)s',
)
parser.add_argument(
'--lock-auto-approve-count',
action='store_true',
help='The first time this option is given, --service-auto-approve-count '
'and --user-auto-approve-count values are stored inside caucase database '
'and will not be altered by further invocations. Once the respective '
'certificate issuance counters reach these values, no further '
'certificates will be ever automatically signed.'
)
backup_group = parser.add_argument_group(
'Backup options',
)
backup_group.add_argument(
'--backup-directory',
help='Backup file path template. Backups will be periodically stored in '
'given directory, encrypted with all certificates which are valid at the '
'time of backup generation. Any one of the associated private keys can '
'decypher it. If not set, no backup will be created.',
)
backup_group.add_argument(
'--backup-period',
default=1,
type=float,
help='Number of days between backups. default: %(default)s'
)
backup_group.add_argument(
'--restore-backup',
nargs=4,
metavar=('BACKUP_PATH', 'KEY_PATH', 'CSR_PATH', 'CRT_PATH'),
help='Restore the file at BACKUP_PATH, decyphering it with the key '
'at KEY_PATH, revoking corresponding certificate and issuing a new '
'one in CRT_PATH using the public key in CSR_PATH. '
'If database is not empty, nothing is done. '
'Then process will exit and must be restarted wihtout this option.',
)
args = parser.parse_args(argv)
# pylint: disable=unused-argument
def onTERM(signum, stack):
"""
Sigterm handler
"""
# The main objective of this signal hander is to fix coverage scores:
# without it, it seems hits generated by this process do not get
# accounted for (atexit not called ?). With it, interpreter shutdown
# seems nicer.
raise SystemExit
# pylint: enable=unused-argument
signal.signal(signal.SIGTERM, onTERM)
base_url = u'http://' + args.netloc.decode('ascii')
parsed_base_url = urlparse(base_url)
hostname = parsed_base_url.hostname
http_port = parsed_base_url.port
cau_crt_life_time = args.user_crt_validity
cau_db_kw = {
'table_prefix': 'cau',
'max_csr_amount': args.user_max_csr,
# Effectively disables certificate expiration
'crt_keep_time': cau_crt_life_time,
'crt_read_keep_time': cau_crt_life_time,
'enforce_unique_key_id': True,
}
cau_kw = {
'ca_subject_dict': {
'CN': u'Caucase CAU' + (
u'' if base_url is None else u' at ' + base_url + '/cau'
),
},
'ca_key_size': args.key_len,
'crt_life_time': cau_crt_life_time,
'auto_sign_csr_amount': args.user_auto_approve_count,
'lock_auto_sign_csr_amount': args.lock_auto_approve_count,
}
if args.restore_backup:
(
backup_path,
backup_key_path,
backup_csr_path,
backup_crt_path,
) = args.restore_backup
try:
_, key_pem, _ = utils.getKeyPair(backup_key_path)
except ValueError:
# maybe user extracted their private key ?
key_pem = utils.getKey(backup_key_path)
with open(backup_path) as backup_file:
with open(backup_crt_path, 'a') as new_crt_file:
new_crt_file.write(
UserCertificateAuthority.restoreBackup(
db_class=SQLite3Storage,
db_path=args.db,
read=backup_file.read,
key_pem=key_pem,
csr_pem=utils.getCertRequest(backup_csr_path),
db_kw=cau_db_kw,
kw=cau_kw,
),
)
return
cau = UserCertificateAuthority(
storage=SQLite3Storage(
db_path=args.db,
**cau_db_kw
),
**cau_kw
)
cas = CertificateAuthority(
storage=SQLite3Storage(
db_path=args.db,
table_prefix='cas',
max_csr_amount=args.service_max_csr,
),
ca_subject_dict={
'CN': u'Caucase CAS' + (
u'' if base_url is None else u' at ' + base_url + '/cas'
),
},
crl_base_url=None if base_url is None else base_url + u'/cas/crl',
ca_key_size=args.key_len,
crt_life_time=args.service_crt_validity,
auto_sign_csr_amount=args.service_auto_approve_count,
lock_auto_sign_csr_amount=args.lock_auto_approve_count,
)
application = Application(cau=cau, cas=cas)
http = make_server(
host=hostname,
port=http_port,
app=application,
server_class=ThreadingWSGIServer,
handler_class=CaucaseWSGIRequestHandler,
)
https = make_server(
host=hostname,
port=443 if http_port == 80 else http_port + 1,
app=application,
server_class=ThreadingWSGIServer,
handler_class=CaucaseSSLWSGIRequestHandler,
)
next_deadline = next_ssl_update = updateSSLContext(
https=https,
key_len=args.key_len,
threshold=args.threshold,
server_key_path=args.server_key,
hostname=hostname,
cau=cau,
cas=cas,
wrap=True,
)
if args.backup_directory:
backup_period = datetime.timedelta(args.backup_period, 0)
try:
next_backup = max(
datetime.datetime.utcfromtimestamp(os.stat(x).st_ctime)
for x in glob.iglob(
os.path.join(args.backup_directory, '*' + BACKUP_SUFFIX),
)
) + backup_period
except ValueError:
next_backup = datetime.datetime.utcnow()
next_deadline = min(
next_deadline,
next_backup,
)
else:
next_backup = None
startServerThread(http)
startServerThread(https)
try:
while True:
now = utils.until(next_deadline)
if now >= next_ssl_update:
next_ssl_update = updateSSLContext(
https=https,
key_len=args.key_len,
threshold=args.threshold,
server_key_path=args.server_key,
hostname=hostname,
cau=cau,
cas=cas,
)
if next_backup is None:
next_deadline = next_ssl_update
else:
if now >= next_backup:
tmp_backup_fd, tmp_backup_path = tempfile.mkstemp(
prefix='caucase_backup_',
)
with os.fdopen(tmp_backup_fd, 'w') as backup_file:
result = cau.doBackup(backup_file.write)
if result:
backup_path = os.path.join(
args.backup_directory,
now.strftime('%Y%m%d%H%M%S') + BACKUP_SUFFIX,
)
os.rename(tmp_backup_path, backup_path)
next_backup = now + backup_period
else:
os.unlink(tmp_backup_path)
next_backup = now + datetime.timedelta(0, 3600)
next_deadline = min(
next_ssl_update,
next_backup,
)
except utils.SleepInterrupt:
pass
.ui-table th, .ui-table td {
line-height: 1.5em;
text-align: left;
padding: .4em .5em;
vertical-align: middle;
}
.ui-overlay-a, .ui-page-theme-a, .ui-page-theme-a .ui-panel-wrapper {
background-color: #fff !important;
}
table .ui-table th, table .ui-table td {
vertical-align: middle;
}
.noshadow buton, .noshadow a, .noshadow input, .noshadow select {
-webkit-box-shadow: none !important;
-moz-box-shadow: none !important;
box-shadow: none !important;
}
.ui-error, html .ui-content .ui-error a, .ui-content a.ui-error {
color: red;
font-weight: bold;
}
html body {
overflow-x: hidden;
background: #fbfbfb;
}
.form-signin {
max-width: 330px;
padding: 15px;
margin: 0 auto;
}
.form-signin .form-signin-heading,
.form-signin .checkbox {
margin-bottom: 10px;
}
.form-signin .checkbox {
font-weight: normal;
}
.form-signin .form-control {
position: relative;
height: auto;
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
padding: 10px;
font-size: 16px;
}
.form-signin .form-control:focus {
z-index: 2;
}
.form-signin input[type="email"] {
margin-bottom: -1px;
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
}
.form-signin input[type="password"] {
margin-bottom: 10px;
border-top-left-radius: 0;
border-top-right-radius: 0;
}
/* Toggle Styles */
#wrapper {
padding-left: 0;
-webkit-transition: all 0.6s ease;
-moz-transition: all 0.6s ease;
-o-transition: all 0.6s ease;
transition: all 0.6s ease;
}
#wrapper.toggled {
padding-left: 200px;
}
#sidebar-wrapper {
z-index: 1000;
position: fixed;
left: 250px;
width: 0;
height: 100%;
margin-left: -250px;
overflow-y: auto;
background-color:#312A25 !Important;
-webkit-transition: all 0.2s ease;
-moz-transition: all 0.2s ease;
-o-transition: all 0.2s ease;
transition: all 0.2s ease;
}
#wrapper.toggled #sidebar-wrapper {
width: 0;
}
#page-content-wrapper {
width: 100%;
position: absolute;
padding: 10px;
}
#wrapper.toggled #page-content-wrapper {
position: absolute;
margin-left:-250px;
}
/* Sidebar Styles */
.nav-side-menu {
overflow: auto;
font-family: verdana;
font-size: 12px;
font-weight: 200;
background-color: #2a2f35; /* #313130 */
position: fixed;
top: 0px;
width: 300px;
height: 100%;
color: #e1ffff;
}
.nav-side-menu .brand {
background-color: #404040;
line-height: 50px;
display: block;
text-align: center;
font-size: 14px;
}
.nav-side-menu .toggle-btn {
display: none;
}
.nav-side-menu ul,
.nav-side-menu li {
list-style: none;
padding: 0px;
margin: 0px;
line-height: 35px;
cursor: pointer;
/*
.collapsed{
.arrow:before{
font-family: FontAwesome;
content: "\f053";
display: inline-block;
padding-left:10px;
padding-right: 10px;
vertical-align: middle;
float:right;
}
}
*/
}
.nav-side-menu ul :not(collapsed) .arrow:before,
.nav-side-menu li :not(collapsed) .arrow:before {
font-family: FontAwesome;
content: "\f078";
display: inline-block;
padding-left: 10px;
padding-right: 10px;
vertical-align: middle;
float: right;
}
.nav-side-menu ul .active,
.nav-side-menu li .active {
border-left: 3px solid #d19b3d;
background-color: #4f5b69;
}
.nav-side-menu ul .sub-menu li.active,
.nav-side-menu li .sub-menu li.active {
color: #d19b3d;
}
.nav-side-menu ul .sub-menu li.active a,
.nav-side-menu li .sub-menu li.active a {
color: #d19b3d;
}
.nav-side-menu ul .sub-menu li,
.nav-side-menu li .sub-menu li {
background-color: #181c20;
border: none;
line-height: 28px;
border-bottom: 1px solid #23282e;
margin-left: 0px;
}
.nav-side-menu ul .sub-menu li:hover,
.nav-side-menu li .sub-menu li:hover {
background-color: #020203;
}
.nav-side-menu ul .sub-menu li:before,
.nav-side-menu li .sub-menu li:before {
font-family: FontAwesome;
content: "\f105";
display: inline-block;
padding-left: 10px;
padding-right: 10px;
vertical-align: middle;
}
.nav-side-menu li {
padding-left: 0px;
border-left: 3px solid #2e353d;
border-bottom: 1px solid #23282e;
}
.nav-side-menu li a {
text-decoration: none;
color: #e1ffff;
display: block;
}
.nav-side-menu li a i {
padding-left: 10px;
width: 20px;
padding-right: 25px;
font-size: 18px;
}
.nav-side-menu li:hover {
border-left: 3px solid #d19b3d;
background-color: #4f5b69;
-webkit-transition: all 1s ease;
-moz-transition: all 1s ease;
-o-transition: all 1s ease;
-ms-transition: all 1s ease;
transition: all 1s ease;
}
@media (max-width: 767px) {
.nav-side-menu {
position: relative;
width: 100%;
margin-bottom: 10px;
}
.nav-side-menu .toggle-btn {
display: block;
cursor: pointer;
position: absolute;
right: 10px;
top: 10px;
z-index: 10 !important;
padding: 3px;
background-color: #ffffff;
color: #000;
width: 40px;
text-align: center;
}
.brand {
text-align: left !important;
font-size: 22px;
padding-left: 20px;
line-height: 50px !important;
}
}
@media (min-width: 767px) {
.nav-side-menu .menu-list .menu-content {
display: block;
}
#main {
width:calc(100% - 300px);
float: right;
}
}
body {
margin: 0px;
padding: 0px;
}
pre {
max-height: 600px;
}
.col-centered {
float: none;
margin: 0 auto;
}
.clickable{
cursor: pointer;
}
.table .panel-heading div {
margin-top: -18px;
font-size: 15px;
}
.table .panel-heading div span{
margin-left:5px;
}
.table .panel-body{
display: none;
}
.container .table>tbody>tr>td, .table>tbody>tr>th, .table>tfoot>tr>td, .table>tfoot>tr>th, .table>thead>tr>td, .table>thead>tr>th {
vertical-align: middle;
}
.margin-top-40 {
margin-top:40px;
}
.margin-lr-20 {
margin: 0 20px;
}
.flashes-messages div:first-child {
margin-top: 30px;
}
/* Dashboard boxes */
.dash-panel {
text-align: center;
padding: 1px 0;
}
html body a:hover > .dash-panel h4 {
text-decoration: none;
}
.dash-panel:hover {
background-color: #e6e6e6;
border-color: #adadad;
cursor: pointer;
}
.dash {
position: relative;
text-align: center;
width: 120px;
height: 55px;
margin: 10px auto 10px auto;
}
#dash-blue .number {
color: #30a5ff;
}
#dash-orange .number {
color: #ffb53e;
}
#dash-teal .number {
color: #1ebfae;
}
#dash-red .number {
color: #ef4040;
}
#dash-darkred .number {
color: #bd0849;
}
.dash .number {
display: block;
position: absolute;
font-size: 46px;
width: 120px;
}
.alert-error {
color: #a94442;
background-color: #f2dede;
border-color: #ebccd1;
}
$( document ).ready(function() {
$("#menu-toggle").click(function(e) {
e.preventDefault();
$("#wrapper").toggleClass("toggled");
});
});
(function(){
'use strict';
var $ = jQuery;
$.fn.extend({
filterTable: function(){
return this.each(function(){
$(this).on('keyup', function(e){
$('.filterTable_no_results').remove();
var $this = $(this),
search = $this.val().toLowerCase(),
target = $this.attr('data-filters'),
$target = $(target),
$rows = $target.find('tbody tr');
if(search === '') {
$rows.show();
} else {
$rows.each(function(){
var $this = $(this);
$this.text().toLowerCase().indexOf(search) === -1 ? $this.hide() : $this.show();
});
if($target.find('tbody tr:visible').size() === 0) {
var col_count = $target.find('tr').first().find('td').size();
var no_results = $('<tr class="filterTable_no_results"><td colspan="'+col_count+'">No results found</td></tr>');
$target.find('tbody').append(no_results);
}
}
});
});
}
});
$('[data-action="filter"]').filterTable();
})(jQuery);
$(function(){
// attach table filter plugin to inputs
$('[data-action="filter"]').filterTable();
$('.container').on('click', '.panel-heading span.filter', function(e){
var $this = $(this),
$panel = $this.parents('.panel');
$panel.find('.panel-body').slideToggle();
if($this.css('display') != 'none') {
$panel.find('.panel-body input').focus();
}
});
$('[data-toggle="tooltip"]').tooltip();
});
\ No newline at end of file
...@@ -15,591 +15,546 @@ ...@@ -15,591 +15,546 @@
# #
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with caucase. If not, see <http://www.gnu.org/licenses/>. # 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
from random import getrandbits
import os import os
import errno import sqlite3
import uuid from threading import local
import hashlib from time import time
from datetime import datetime, timedelta from .exceptions import NoStorage, NotFound, Found
from OpenSSL import crypto
from caucase import db __all__ = ('SQLite3Storage', )
from caucase import utils
from caucase.exceptions import (NoStorage, NotFound, Found, ExpiredCertificate) DAY_IN_SECONDS = 60 * 60 * 24
from flask_user import UserMixin
class SQLite3Storage(local):
STATUS_VALIDATED = 'validated' """
STATUS_REVOKED = 'invalidated' CA data storage.
STATUS_REJECTED = 'rejected'
STATUS_PENDING = 'pending' Every cryptographic type this class deals with must be PEM-encoded.
"""
class Storage(object): def __init__(
self,
def __init__(self, db_instance, max_csr_amount=None, db_path,
crt_keep_time=None, csr_keep_time=None): table_prefix,
max_csr_amount=50,
self.db = db_instance crt_keep_time=1,
crt_read_keep_time=0.05, # About 1 hour
# initialise tables enforce_unique_key_id=False,
self.db.create_all() ):
"""
# store some config in storage db_path (str)
if max_csr_amount: SQLite connection string.
self.__setConfig('max-csr-amount', max_csr_amount) table_prefix (str)
if crt_keep_time is not None: Name to use as a prefix for all tables managed by this storage adapter.
self.__setConfig('crt-keep-time', crt_keep_time) # 0 mean always keep in storage Allows sharing the database, although it should only be within the same
if csr_keep_time is not None: caucase process for access permission reasons.
self.__setConfig('csr-keep-time', csr_keep_time) # 0 mean always keep non pending csr in storage max_csr_amount (int)
Maximum number of allowed pending certificate signing requests.
def _getConfig(self, key): To prevent flood.
return Config.query.filter(Config.key == key).first() crt_keep_time (float)
Time to keep signed certificates for, in days.
def __setConfig(self, key, value): crt_read_keep_time (float)
""" Time to keep CRT content for after it was first read, in days.
Add new config to storage Allows requester to fail retrieving the certificate by tolerating
""" retries.
entry = self._getConfig(key) enforce_unique_key_id (bool)
if not entry: When true, certificate requests cannot be appended if there is already
entry = Config(key=key, value='%s' % value) and known entry for the same private key.
self.db.session.add(entry) Note: this only makes sense if crt_keep_time and crt_read_keep_time are
set at least to the certificate life span.
Useful for backups, to ensure the certificate to revoke can be uniquely
identified from the key used to decrypt the backup archive.
"""
super(SQLite3Storage, self).__init__()
self._db = db = sqlite3.connect(db_path)
self._table_prefix = table_prefix
db.row_factory = sqlite3.Row
self._max_csr_amount = max_csr_amount
self._crt_keep_time = crt_keep_time * DAY_IN_SECONDS
self._crt_read_keep_time = crt_read_keep_time * DAY_IN_SECONDS
with db:
# Note about revoked.serial: certificate serials exceed the 63 bits
# sqlite can accept as integers, so store these as text. Use a trivial
# string serialisation: not very space efficient, but this should not be
# a limiting issue for our use-cases anyway.
db.cursor().executescript('''
CREATE TABLE IF NOT EXISTS %(prefix)sca (
expiration_date INTEGER,
key TEXT,
crt TEXT
);
CREATE TABLE IF NOT EXISTS %(prefix)scrt (
id INTEGER PRIMARY KEY,
key_id TEXT %(key_id_constraint)s,
expiration_date INTEGER,
csr TEXT,
crt TEXT
);
CREATE TABLE IF NOT EXISTS %(prefix)srevoked (
serial TEXT PRIMARY KEY,
revocation_date INTEGER,
expiration_date INTEGER
);
CREATE TABLE IF NOT EXISTS %(prefix)scrl (
expiration_date INTEGER,
crl TEXT
);
CREATE TABLE IF NOT EXISTS %(prefix)scounter (
name TEXT PRIMARY KEY,
value INTEGER
);
CREATE TABLE IF NOT EXISTS %(prefix)sconfig_once (
name TEXT PRIMARY KEY,
value TEXT
)
''' % {
'prefix': table_prefix,
'key_id_constraint': 'UNIQUE' if enforce_unique_key_id else '',
})
def _incrementCounter(self, name, increment=1, initial=0):
"""
Increment counter with <name> by <increment> and return resulting value.
If <name> is not found, it is created with <initial>, and then incremented.
Does not commit.
"""
row = self._executeSingleRow(
'SELECT value FROM %scounter WHERE name = ? LIMIT 2' % (
self._table_prefix,
),
(name, ),
)
if row is None:
value = initial
else: else:
# update value value = row['value']
entry.value = value value += increment
self.db.session.commit() self._db.cursor().execute(
'INSERT OR REPLACE INTO %scounter (name, value) VALUES (?, ?)' % (
self._table_prefix,
),
(name, value),
)
return value
def getConfig(self, key, default=None): def _executeSingleRow(self, sql, parameters=()):
""" """
Return a config value or default Execute <sql>, raise if it produces more than 1 row, and return it.
"""
result_list = self._db.cursor().execute(sql, parameters).fetchall()
if result_list:
result, = result_list
return result
def getConfigOnce(self, name, default):
""" """
entry = self._getConfig(key) Retrieve the value of <name> from config-once list, or <default> if not
if not entry: stored.
"""
with self._db:
result = self._executeSingleRow(
'SELECT value FROM %sconfig_once WHERE name = ?' % (
self._table_prefix,
),
(name, ),
)
if result is None:
return default return default
return entry.value return result['value']
def _getMaxCsrCount(self): def setConfigOnce(self, name, value):
return int(self.getConfig('max-csr-amount', 50)) """
Store <value> as <name> in config-once list, if it was not already stored.
If it was already stored, do nothing.
"""
try:
with self._db as db:
db.cursor().execute(
'INSERT INTO %sconfig_once (name, value) VALUES (?, ?)' % (
self._table_prefix,
),
(name, value),
)
except sqlite3.IntegrityError:
pass
def getCAKeyPairList(self): def getCAKeyPairList(self):
""" """
Return the chronologically sorted (oldest in [0], newest in [-1]) certificate authority Return the chronologically sorted (oldest in [0], newest in [-1])
key pairs. certificate authority key pairs.
""" """
item_list = CAKeypair.query.filter( with self._db as db:
CAKeypair.active == True c = db.cursor()
).order_by( c.execute(
CAKeypair.creation_date.asc() 'DELETE FROM %sca WHERE expiration_date < ?' % (
).all() self._table_prefix,
),
if not item_list: (time(), ),
return [] )
return [
keypair_list = [] {
for keypair in item_list: 'crt_pem': x['crt'].encode('ascii'),
keypair_list.append({ 'key_pem': x['key'].encode('ascii'),
'crt': crypto.load_certificate(crypto.FILETYPE_PEM, keypair.certificate), }
'key': crypto.load_privatekey(crypto.FILETYPE_PEM, keypair.key) for x in db.cursor().execute(
}) 'SELECT key, crt FROM %sca ORDER BY expiration_date ASC' % (
self._table_prefix,
return keypair_list ),
).fetchall()
]
def storeCAKeyPair(self, key_pair): def appendCAKeyPair(self, expiration_timestamp, key_pair):
""" """
Store a certificate authority key pair. Store a certificate authority key pair.
""" expiration_timestamp (int)
serial = utils.getSerialToInt(key_pair['crt']) Unix GMT timestamp of CA certificate "valid until" date.
crt_string = crypto.dump_certificate(crypto.FILETYPE_PEM, key_pair['crt']) key_pair (dict with 'key' and 'crt' items)
key_string = crypto.dump_privatekey(crypto.FILETYPE_PEM, key_pair['key']) CA key pair to store, as bytes.
"""
# check that keypair is not stored with self._db as db:
keypair = CAKeypair.query.filter( db.cursor().execute(
CAKeypair.active == True 'INSERT INTO %sca (expiration_date, key, crt) VALUES (?, ?, ?)' % (
).filter( self._table_prefix,
CAKeypair.serial == serial
).first()
if keypair:
raise Found('Another CA certificate exists with serial %r' % (serial, ))
saved_pair = CAKeypair(
serial=serial,
common_name=key_pair['crt'].get_subject().CN,
expire_after=datetime.strptime(
key_pair['crt'].get_notAfter(), '%Y%m%d%H%M%SZ'
), ),
start_before=datetime.strptime( (
key_pair['crt'].get_notBefore(), '%Y%m%d%H%M%SZ' expiration_timestamp,
key_pair['key_pem'],
key_pair['crt_pem'],
), ),
key=key_string,
certificate=crt_string,
active=True,
creation_date=datetime.utcnow()
) )
self.db.session.add(saved_pair) def appendCertificateSigningRequest(self, csr_pem, key_id, override_limits=False):
self.db.session.commit()
def storeCertificateSigningRequest(self, csr):
""" """
Store acertificate signing request and generate a unique ID for it. Store acertificate signing request and generate a unique ID for it.
Note: ID uniqueness is only guaranteed among pending CSR, and may be reused
after the original CSR has been discarded (by being rejected or signed).
""" """
csr_amount = self.countPendingCertificateSiningRequest() with self._db as db:
if csr_amount >= self._getMaxCsrCount(): known_csr = self._executeSingleRow(
raise NoStorage('Too many pending CSRs') 'SELECT id FROM %scrt WHERE csr = ? LIMIT 2' % (
self._table_prefix,
content = crypto.dump_certificate_request(crypto.FILETYPE_PEM, csr) ),
checksum = hashlib.md5(content).hexdigest() (csr_pem, ),
check_csr = CertificateRequest.query.filter( )
CertificateRequest.status == STATUS_PENDING if known_csr is not None:
).filter( return known_csr['id'], None
CertificateRequest.checksum == checksum if override_limits:
).first() # Ignore max pending count
if check_csr: # Do not increment the number of auto-signed certificates, but do not
# this only prevent client loop sending the same csr until csr_amount is reached # automatically sign either.
return check_csr.csr_id requested_count = None
key = str(uuid.uuid1().hex)
csr_id = '%s.csr.pem' % key
crt_id = '%s.crt.pem' % key
req = CertificateRequest(
content=content,
creation_date=datetime.utcnow(),
common_name=csr.get_subject().CN,
checksum=checksum,
csr_id=csr_id,
crt_id=crt_id)
request_amount = self._getConfig('csr-requested-amount')
if not request_amount:
request_amount = Config(key='csr-requested-amount', value='1')
self.db.session.add(request_amount)
else:
request_amount.value = '%s' % (int(request_amount.value) + 1,)
self.db.session.add(req)
self.db.session.commit()
return csr_id
def deletePendingCertificateRequest(self, csr_id):
csr = CertificateRequest.query.filter(
CertificateRequest.status == STATUS_PENDING
).filter(
CertificateRequest.csr_id == csr_id
).first()
if csr:
self.db.session.delete(csr)
self.db.session.commit()
else: else:
raise NotFound('No pending CSR with id %r' % (csr_id, )) if self._executeSingleRow(
'SELECT COUNT(*) FROM %scrt WHERE crt IS NULL' % (
def getPendingCertificateRequest(self, csr_id): self._table_prefix,
csr = CertificateRequest.query.filter(
CertificateRequest.status == STATUS_PENDING
).filter(
CertificateRequest.csr_id == csr_id
).first()
if csr:
return csr.content
raise NotFound('No pending CSR with id %r' % (csr_id, ))
def getPendingCertificateRequestList(self, limit=0, with_data=False):
"""
Return list of all CSR
"""
data_list = []
index = 1
query = CertificateRequest.query.filter(
CertificateRequest.status == STATUS_PENDING
) )
if limit > 0: )[0] >= self._max_csr_amount:
query.limit(limit) raise NoStorage
csr_list = query.all() requested_count = self._incrementCounter('received_csr')
for request_csr in csr_list: csr_id = getrandbits(63)
csr = { c = db.cursor()
'index': index, c.execute(
'csr_id': request_csr.csr_id, 'INSERT INTO %scrt (id, key_id, csr) VALUES (?, ?, ?)' % (
'crt_id': request_csr.crt_id, self._table_prefix,
'common_name': request_csr.common_name, ),
'creation_date': request_csr.creation_date (
} csr_id,
if with_data: key_id,
certificate['content'] = request_csr.content csr_pem,
data_list.append(csr) ),
index += 1
return data_list
def storeCertificate(self, csr_id, crt):
"""
Store certificate as crt_id. crt is a certificate PEM object.
"""
csr = csr = CertificateRequest.query.filter(
CertificateRequest.status == STATUS_PENDING
).filter(
CertificateRequest.csr_id == csr_id
).first()
if not csr:
raise NotFound('No pending CSR with id %r' % (csr_id, ))
cert = Certificate.query.filter(
Certificate.status == STATUS_VALIDATED
).filter(
Certificate.crt_id == csr.crt_id
).first()
if cert:
raise Found('CRT already exists')
cert_db = Certificate(
crt_id=csr.crt_id,
serial=utils.getSerialToInt(crt),
common_name=crt.get_subject().CN,
expire_after=datetime.strptime(crt.get_notAfter(), '%Y%m%d%H%M%SZ'),
start_before=datetime.strptime(crt.get_notBefore(), '%Y%m%d%H%M%SZ'),
creation_date=datetime.utcnow(),
content=crypto.dump_certificate(crypto.FILETYPE_PEM, crt)
) )
# Change Csr status as 'validated', so it can be trashed c.execute(
csr.status = STATUS_VALIDATED 'DELETE FROM %scrt WHERE expiration_date < ?' % (
self._table_prefix,
self.db.session.add(cert_db) ),
self.db.session.commit() (time(), ),
return csr.crt_id
def getCertificateFromSerial(self, serial):
cert = Certificate.query.filter(
Certificate.status == STATUS_VALIDATED
).filter(
Certificate.serial == '%s' % serial
).first()
if cert:
return cert
raise NotFound('No certficate with serial %r' % (serial, ))
# schedule certificate removal
def getCertificate(self, crt_id):
cert = Certificate.query.filter(
Certificate.status == STATUS_VALIDATED
).filter(
Certificate.crt_id == crt_id
).first()
if cert and cert.content:
# if content is emtpy, maybe the certificate content was stripped ?
return cert.content
raise NotFound('No certficate with id %r' % (crt_id, ))
# schedule certificate removal
def getSignedCertificateList(self, limit=0, with_data=False):
data_list = []
index = 1
query = Certificate.query.filter(
Certificate.status == STATUS_VALIDATED
) )
if limit > 0: return csr_id, requested_count
query.limit(limit)
signed_cert_list = query.all()
for signed_cert in signed_cert_list:
certificate = {
'index': index,
'serial': signed_cert.serial,
'crt_id': signed_cert.crt_id,
'common_name': signed_cert.common_name,
'expire_after': signed_cert.expire_after,
'start_before': signed_cert.start_before,
}
if with_data:
certificate['content'] = signed_cert.content
data_list.append(certificate)
index += 1
return data_list
def revokeCertificate(self, serial=None, crt_id=None, reason=''): def deletePendingCertificateSigningRequest(self, csr_id):
""" """
Add serial to the list of revoked certificates. Forget about a pending CSR. Does nothing if the CSR was already signed
Associated certificate must expire at (or before) not_after_date, so (it will be automatically garbage-collected later).
revocation can be pruned. Raises NotFound if there is no matching CSR.
serial or crt_id should be send to get the certificate. If both are set,
serial is used.
""" """
if serial is None and crt_id is None: with self._db as db:
raise ValueError("serial or crt_id are not set to revokeCertificate.") c = db.cursor()
c.execute(
query = Certificate.query.filter(Certificate.status == STATUS_VALIDATED) 'DELETE FROM %scrt WHERE id = ? AND crt IS NULL' % (
if serial: self._table_prefix,
query = query.filter(Certificate.serial == serial) ),
else: (csr_id, ),
query = query.filter(Certificate.crt_id == crt_id)
cert = query.first()
if not cert:
raise NotFound('No certficate with serial or id %r found!' % (
serial or crt_id, ))
expire_in = cert.expire_after - datetime.utcnow()
if expire_in.days < 0:
raise ExpiredCertificate("Certificate with serial %r has expired" \
" since %r day(s)." % (serial, -1*expire_in.days))
revoke = Revocation(
serial=cert.serial,
creation_date=datetime.utcnow(),
reason=reason,
crt_expire_after=cert.expire_after
) )
# Set latest CRL as expired, it will be regenerated if c.rowcount == 1:
crl = CertificateRevocationList.query.filter( return
CertificateRevocationList.active == True raise NotFound
).first()
if crl:
crl.active = False
# this certificate is not valid anymore
cert.status = STATUS_REVOKED
self.db.session.add(revoke)
self.db.session.commit()
def getCertificateRevocationList(self): def getCertificateSigningRequest(self, csr_id):
"""
Get Certificate Rovocation List of None if there is no valid CRL
""" """
last_revocation = CertificateRevocationList.query.order_by( Retrieve a PEM-encoded certificate signing request.
CertificateRevocationList.id.desc()
).first()
if last_revocation and last_revocation.active: csr_id (int)
if (last_revocation.crl_expire_after - datetime.utcnow()).days >= 0: Desired CSR id, as given when the CSR was stored.
return last_revocation.content
return None
def getNextCRLVersionNumber(self):
last_revocation = CertificateRevocationList.query.order_by(
CertificateRevocationList.id.desc()
).first()
if last_revocation:
return last_revocation.id + 1
else:
return 1
def storeCertificateRevocationList(self, crl, expiration_date):
"""
Store Certificate Revocation List, return stored crl string
XXX - send expiration_date because crypto.crl has no method to read this from crl object Raises NotFound if there is no matching CSR.
""" """
# Fetch cached CRL (or re-generate and store if not cached). with self._db:
dumped_crl = crypto.dump_crl(crypto.FILETYPE_PEM, crl) result = self._executeSingleRow(
'SELECT csr FROM %scrt WHERE id = ?' % (
revocation_list = CertificateRevocationList( self._table_prefix,
creation_date=datetime.utcnow(), ),
crl_expire_after=expiration_date, (csr_id, ),
content=dumped_crl,
active=True
) )
self.db.session.add(revocation_list) if result is None:
self.db.session.commit() raise NotFound
return dumped_crl return result['csr'].encode('ascii')
def getRevocationList(self): def getCertificateSigningRequestList(self):
"""
Get the list of all revoked certificate which are not expired
""" """
return Revocation.query.filter( Return the list of all pending CSRs.
Revocation.crt_expire_after >= datetime.utcnow()
).all()
def getCertificateSigningRequestAmount(self): Ignores any CSR for which a certificate was issued.
""" """
Return number of CSR which was requested until now with self._db as db:
""" return [
return int(self.getConfig('csr-requested-amount', 0)) {
'id': x['id'],
def countValidatedCertificate(self): 'csr': x['csr'].encode('ascii'),
return Certificate.query.filter( }
Certificate.status == STATUS_VALIDATED for x in db.cursor().execute(
).count() 'SELECT id, csr FROM %scrt WHERE crt IS NULL' % (
self._table_prefix,
def countPendingCertificateSiningRequest(self): ),
return CertificateRequest.query.filter( ).fetchall()
CertificateRequest.status == STATUS_PENDING ]
).count()
def countRevokedCertificate(self):
return Certificate.query.filter(
Certificate.status == STATUS_REVOKED
).count()
def countCertificateRevocation(self):
return Revocation.query.count()
def storeCertificate(self, csr_id, crt):
"""
Store certificate for pre-existing CSR.
def housekeep(self): Raises NotFound if there is no matching CSR, or if a certificate was
already stored.
""" """
Remove outdated certificates (because they were retrieved long ago), with self._db as db:
ca certificates (because they exceeded their "not valid after" date), c = db.cursor()
revocation of anway-expired certificates. c.execute(
'UPDATE %scrt SET crt=?, expiration_date = ? '
'WHERE id = ? AND crt IS NULL' % (
self._table_prefix,
),
(
crt,
int(time() + self._crt_keep_time),
csr_id,
),
)
if c.rowcount == 0:
raise NotFound
def getCertificate(self, crt_id):
""" """
crt_keep_time = int(self.getConfig('crt-keep-time', 0)) Retrieve a PEM-encoded certificate.
csr_keep_time = int(self.getConfig('csr-keep-time', 0))
expired_keypair_list = CAKeypair.query.filter( crt_id (int)
CAKeypair.expire_after < datetime.utcnow() Desired certificate id, which is the same as corresponding CSR's id.
).all()
for key_pair in expired_keypair_list:
# Desactivate this ca certificate
key_pair.active = False
# wipe certificate content Raises NotFound if there is no matching CRT or if no certificate was issued
if crt_keep_time > 0: for it.
check_date = datetime.utcnow() - timedelta(0, crt_keep_time) """
cert_list = Certificate.query.filter( with self._db as db:
Certificate.creation_date <= check_date row = self._executeSingleRow(
'SELECT crt, expiration_date FROM %scrt '
'WHERE id = ? AND crt IS NOT NULL' % (
self._table_prefix,
),
(crt_id, ),
) )
for cert in cert_list: if row is None:
# clear x509 certificate information raise NotFound
cert.content = "" new_expiration_date = int(time() + self._crt_read_keep_time)
if row['expiration_date'] > new_expiration_date:
# delete certificate request db.cursor().execute(
if csr_keep_time > 0: 'UPDATE %scrt SET expiration_date = ? WHERE id = ?' % (
check_date = datetime.utcnow() - timedelta(0, csr_keep_time) self._table_prefix,
csr_list = CertificateRequest.query.filter( ),
CertificateRequest.status != STATUS_PENDING (
).filter( new_expiration_date,
CertificateRequest.creation_date <= check_date crt_id,
) )
for csr in csr_list:
self.db.session.delete(csr)
# delete all expired Certificate Rovocation
revocation_list = Revocation.query.filter(
Revocation.crt_expire_after < datetime.utcnow()
).all()
for revocation in revocation_list:
self.db.session.delete(revocation)
# Delete all expired Certificate Rovocation List (CRL)
crl_list = CertificateRevocationList.query.filter(
CertificateRevocationList.crl_expire_after < datetime.utcnow()
).all()
for crl in crl_list:
self.db.session.delete(crl)
self.db.session.commit()
def findOrCreateUser(self, first_name, last_name, email, username, hash_password):
""" Find existing user or create new user """
user = User.query.filter(User.username == username).first()
if not user:
user = User(email=email,
first_name=first_name,
last_name=last_name,
username=username,
password=hash_password,
active=True,
confirmed_at=datetime.utcnow()
) )
db.session.add(user) return row['crt'].encode('ascii')
db.session.commit()
return user
def findUser(self, username): def getCertificateByKeyIdentifier(self, key_id):
return User.query.filter(User.username == username).first() """
Return the certificate corresponding to given key_id.
class User(db.Model, UserMixin):
id = db.Column(db.Integer, primary_key=True)
# User authentication information
username = db.Column(db.String(50), nullable=False, unique=True)
password = db.Column(db.String(255), nullable=False, server_default='')
# User email information
email = db.Column(db.String(255), nullable=False, unique=True)
confirmed_at = db.Column(db.DateTime())
# User information Raises NotFound if there is no matching CRT or if no certificate was issued
active = db.Column('is_active', db.Boolean(), nullable=False, server_default='0') for it.
first_name = db.Column(db.String(100), nullable=False, server_default='') """
last_name = db.Column(db.String(100), nullable=False, server_default='') with self._db:
row = self._executeSingleRow(
'SELECT crt FROM %scrt WHERE key_id = ? AND crt IS NOT NULL' % (
self._table_prefix,
),
(key_id, ),
)
if row is None:
raise NotFound
return row['crt'].encode('ascii')
def iterCertificates(self):
"""
Iterator over stored certificates.
"""
with self._db as db:
c = db.cursor()
c.execute('SELECT crt FROM %scrt WHERE crt IS NOT NULL' % (
self._table_prefix,
))
while True:
row = c.fetchone()
if row is None:
break
yield row['crt'].encode('ascii')
def revoke(self, serial, expiration_date):
"""
Add given certificate serial to the list of revoked certificates.
Flushes any current CRL.
serial (int)
Serial of the certificate to revoke.
expiration_date (int)
Unix timestamp at which the certificate expires, allowing to remove this
entry from the CRL.
"""
with self._db as db:
c = db.cursor()
c.execute('DELETE FROM %scrl' % (
self._table_prefix,
))
try:
c.execute(
'INSERT INTO %srevoked '
'(serial, revocation_date, expiration_date) '
'VALUES (?, ?, ?)' % (
self._table_prefix,
),
(
str(serial),
int(time()),
expiration_date,
)
)
except sqlite3.IntegrityError:
raise Found
class Config(db.Model): def getCertificateRevocationList(self):
""" """
This table store some configs and information Get PEM-encoded current Certificate Revocation List.
Returns None if there is no CRL.
""" """
__tablename__ = 'config' with self._db:
key = db.Column(db.String(50), primary_key=True) row = self._executeSingleRow(
value = db.Column(db.Text) 'SELECT crl FROM %scrl '
'WHERE expiration_date > ? ORDER BY expiration_date DESC LIMIT 1' % (
self._table_prefix,
),
(time(), )
)
if row is not None:
return row['crl'].encode('ascii')
class CAKeypair(db.Model): def getNextCertificateRevocationListNumber(self):
""" """
This table is used ca certificate key pair Get next CRL sequence number.
""" """
__tablename__ = 'ca_keypair' return self._incrementCounter('crl_number')
id = db.Column(db.Integer, primary_key=True)
serial = db.Column(db.String(50), unique=True)
expire_after = db.Column(db.DateTime)
start_before = db.Column(db.DateTime)
common_name = db.Column(db.String(50), unique=False)
active = db.Column(db.Boolean(), nullable=False, server_default='1')
certificate = db.Column(db.Text)
key = db.Column(db.Text)
creation_date = db.Column(db.DateTime)
class CertificateRequest(db.Model): def storeCertificateRevocationList(self, crl, expiration_date):
""" """
This table is used to store certificate signature request Store Certificate Revocation List.
""" """
__tablename__ = 'csr' with self._db as db:
id = db.Column(db.Integer, primary_key=True) c = db.cursor()
csr_id=db.Column(db.String(80), unique=True) c.execute('DELETE FROM %scrl' % (
crt_id = db.Column(db.String(80), unique=True) self._table_prefix,
common_name = db.Column(db.String(50), unique=False) ))
content = db.Column(db.Text) c.execute(
creation_date = db.Column(db.DateTime) 'INSERT INTO %scrl (expiration_date, crl) VALUES (?, ?)' % (
status = db.Column(db.String(20), unique=False, server_default=STATUS_PENDING) self._table_prefix,
# checksum prevent to store twice the same csr ),
checksum = db.Column(db.String(50)) (
int(expiration_date),
crl,
),
)
class Certificate(db.Model): def getRevocationList(self):
""" """
This table is used to store some informations about certificate Get the list of all revoked certificates.
Returns a list of dicts, each containing:
- revocation_date (int)
Unix timestamp of certificate revocation.
- serial (int)
Revoked certificate's serial.
""" """
__tablename__ = 'certificate' with self._db as db:
id = db.Column(db.Integer, primary_key=True) c = db.cursor()
crt_id = db.Column(db.String(80), unique=True) c.execute(
serial = db.Column(db.String(50), unique=True) 'DELETE FROM %srevoked WHERE expiration_date < ?' % (
common_name = db.Column(db.String(50), unique=False) self._table_prefix,
expire_after = db.Column(db.DateTime) ),
start_before = db.Column(db.DateTime) (time(), ),
creation_date = db.Column(db.DateTime) )
# status = validated or revoked return [
status = db.Column(db.String(20), unique=False, server_default=STATUS_VALIDATED) {
content = db.Column(db.Text) 'revocation_date': int(x['revocation_date']),
'serial': int(x['serial']),
}
for x in c.execute(
'SELECT revocation_date, serial FROM %srevoked' % (
self._table_prefix,
),
)
]
class Revocation(db.Model): def dumpIterator(self):
""" """
This table store certificate revocation request from users Backs the *entire* dabase up. This is not limited to tables managed by this
class (so not limited to table_prefix).
""" """
__tablename__ = 'revoked' for statement in self._db.iterdump():
id = db.Column(db.Integer, primary_key=True) yield statement.encode('utf-8') + '\0'
serial = db.Column(db.String(50), unique=False)
crt_expire_after = db.Column(db.DateTime)
reason = db.Column(db.String(200), unique=False)
creation_date = db.Column(db.DateTime)
class CertificateRevocationList(db.Model): @staticmethod
def restore(db_path, restorator):
""" """
This table store certificate revocation list content Restores a dump which is the concatenated output of dumpIterator.
Should not be called directly, but via
ca.CertificateAuthority.restoreBackup .
db_path (str)
Path to the SQLite database to produce. Must not exist.
read (callabla taking an integer as argument)
Must return this many bytes, or an empty string if there is no input
left.
""" """
__tablename__ = 'crl' buf = ''
id = db.Column(db.Integer, primary_key=True) if os.path.exists(db_path):
active = db.Column(db.Boolean(), nullable=False, server_default='1') raise ValueError('%r exists, not restoring.' % (db_path, ))
creation_date = db.Column(db.DateTime) c = sqlite3.connect(db_path, isolation_level=None).cursor()
crl_expire_after = db.Column(db.DateTime) for chunk in restorator:
content = db.Column(db.Text) statement_list = (buf + chunk).split('\0')
buf = statement_list.pop()
for statement in statement_list:
c.execute((statement).decode('utf-8'))
if buf:
raise ValueError('Short read, backup truncated ?')
{% extends "layout.html" %}
{% block content %}
<form class="form-signin" method="POST" action="/admin/setpassword">
<h2 class="form-signin-heading" style="margin-bottom: 30px">Set admin password</h2>
<label for="pw" class="sr-only">Password</label>
<input type="inputPassword" name="password" id="pw" class="form-control" placeholder="password" required autofocus>
<label for="pw2" class="sr-only">Confirm Password:</label>
<input type="inputPassword" name="password2" id="pw2" class="form-control" placeholder="Confirm password" required autofocus>
<br/>
<button class="btn btn-lg btn-primary btn-block" type="submit">configure</button>
</form>
{% endblock %}
{% extends "layout.html" %}
{% block pre_content %}
<div class="row">
<div class="col-sm-7 col-md-6 col-lg-5 col-centered">
{% endblock %}
{% block post_content %}
</div>
</div>
{% endblock %}
\ No newline at end of file
{% extends 'flask_user/flask_user_base.html' %}
\ No newline at end of file
{% extends 'flask_user/flask_user_base.html' %}
\ No newline at end of file
<!-- Placed at the end of the document so the pages load faster -->
<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.12.4/jquery.min.js"></script>
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/js/bootstrap.min.js" integrity="sha384-Tc5IQib027qvyjSMfHjOMaLkfuWVxZxUPnCJA7l2mCWNIpG9mGCD8wGNIcPD7Txa" crossorigin="anonymous"></script>
<script type=application/javascript src="{{ url_for('static', filename='scripts/index.js') }}"></script>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Certificate Authority web</title>
<link rel=stylesheet type=text/css href="{{ url_for('static', filename='css/styles.css') }}">
<link href="//netdna.bootstrapcdn.com/font-awesome/4.2.0/css/font-awesome.min.css" rel="stylesheet" type="text/css" />
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css" integrity="sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u" crossorigin="anonymous">
{% extends "layout.html" %}
{% block content %}
<div class="page-header">
<h1>Certificate authority<small> Signed Certificates</small></h1>
</div>
<div class="row">
<div class="col-md-12">
<div class="panel panel-success margin-top-40 table">
<div class="panel-heading">
<h3 class="panel-title">List of signed Certificates</h3>
<div class="pull-right">
<span class="clickable filter" data-toggle="tooltip" title="Toggle table filter" data-container="body">
<i class="glyphicon glyphicon-filter"></i>
</span>
</div>
</div>
<div class="panel-body">
<input type="text" class="form-control" id="cacert-table-filter" data-action="filter" data-filters="#cacert-table" placeholder="Filter Columns" />
</div>
<table class="table table-hover" id="cacert-table">
<thead>
<tr>
<th>#</th>
<th>Name</th>
<th>Common Name</th>
<th>Signature Date</th>
<th>Expiration Date</th>
<th></th>
</tr>
</thead>
<tbody>
{% for cert in data_list -%}
<tr>
<td>{{ cert['index'] }}</td>
<td><a href="/crt/{{ cert['crt_id'] }}">{{ cert['crt_id'] }}</a></td>
<td>{{ cert['common_name'] }}</td>
<td>{{ cert['start_before'] }}</td>
<td>{{ cert['expire_after'] }}</td>
<td><a class="btn btn-default" href="/crt/{{ cert['crt_id'] }}" role="button" title="Download file"><i class="fa fa-download" aria-hidden="true"></i></a></td>
</tr>
{% endfor -%}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}
\ No newline at end of file
<!DOCTYPE html>
<html>
<head>
{% include "head.html" %}
</head>
<body>
{% block body %}
<div class="container-fluid">
<div class="row">
<div class="nav-side-menu">
<div class="brand">Certificate Authority</div>
<i class="fa fa-bars fa-2x toggle-btn" data-toggle="collapse" data-target="#menu-content"></i>
<div class="menu-list">
<ul id="menu-content" class="menu-content collapse out">
{% if not session.user_id %}
<li>
<a href="/">
<i class="fa fa-home" aria-hidden="true"></i> Public Home
</a>
</li>
<li>
<a href="/user/sign-in">
<i class="fa fa-cog" aria-hidden="true"></i> Manage Certificates
</a>
</li>
{% else -%}
<li>
<a href="/admin/">
<i class="fa fa-home" aria-hidden="true"></i> Signed Certificates
</a>
</li>
<li><a href="/admin/csr_requests"><i class="fa fa-tachometer" aria-hidden="true"></i> Manage CSR
<span style="font-weight: bold; margin-left: 5px; margin-right: 5px; color: #56d8ce;">[ {{ session.count_csr }} ] </span></a></li>
<!--<li><a href="/signed_certs"><i class="fa fa-check-square" aria-hidden="true"></i> Signed Certificates</a></li>
<li><a href="/revoked_certs"><i class="fa fa-minus-square" aria-hidden="true"></i> Revoked Certificates</a></li>-->
<li><a href="/admin/profile"><i class="fa fa-user" aria-hidden="true"></i> User Profile</a></li>
<!--<li><a href="/admin/logs"><i class="fa fa-book" aria-hidden="true"></i> Certificate Authority Logs</a></li>-->
<li><a href="/admin/logout"><i class="fa fa-sign-out" aria-hidden="true"></i> Logout</a></li>
{% endif -%}
</ul>
</div>
</div>
<div class="container" id="main">
<div class="flashes-messages">
{% with messages = get_flashed_messages(with_categories=true) %}
<!-- Categories: success (green), info (blue), warning (yellow), danger (red) -->
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }} alert-dismissible" role="alert">
<button type="button" class="close" data-dismiss="alert" aria-label="Close"><span aria-hidden="true">&times;</span></button>
{{ message|safe }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
</div>
{% block pre_content %}{% endblock %}
{% block content %}{% endblock %}
{% block post_content %}{% endblock %}
</div>
</div>
</div>
{% endblock %}
{% include "footer.html" %}
</body>
</html>
\ No newline at end of file
{% macro render_field(field, label=None, label_visible=true, right_url=None, right_label=None, readonly=false) -%}
<div class="form-group {% if field.errors %}has-error{% endif %} {{ kwargs.pop('class_', '') }}">
{% if field.type != 'HiddenField' and label_visible %}
{% if not label %}{% set label=field.label.text %}{% endif %}
<label for="{{ field.id }}" class="control-label">{{ label|safe }}</label>
{% endif %}
{{ field(class_='form-control', readonly=readonly, **kwargs) }}
{% if field.errors %}
{% for e in field.errors %}
<p class="help-block">{{ e }}</p>
{% endfor %}
{% endif %}
</div>
{%- endmacro %}
{% macro render_checkbox_field(field, label=None) -%}
{% if not label %}{% set label=field.label.text %}{% endif %}
<div class="checkbox">
<label>
{{ field(type='checkbox', **kwargs) }} {{ label }}
</label>
</div>
{%- endmacro %}
{% macro render_radio_field(field) -%}
{% for value, label, checked in field.iter_choices() %}
<div class="radio">
<label>
<input type="radio" name="{{ field.id }}" id="{{ field.id }}" value="{{ value }}"{% if checked %} checked{% endif %}>
{{ label }}
</label>
</div>
{% endfor %}
{%- endmacro %}
{% macro render_submit_field(field, label=None, tabindex=None) -%}
{% if not label %}{% set label=field.label.text %}{% endif %}
{#<button type="submit" class="form-control btn btn-lg btn-primary btn-block">{{label}}</button>#}
<input type="submit" class="btn btn-lg btn-primary btn-block" value="{{label}}"
{% if tabindex %}tabindex="{{ tabindex }}"{% endif %}
>
{%- endmacro %}
\ No newline at end of file
{% extends "layout.html" %}
{% block content %}
<div class="page-header">
<h1>Administration <small>Certificate authority web</small></h1>
</div>
<div class="row">
<div class="col-md-12">
<div class="panel panel-primary margin-top-40 table">
<div class="panel-heading">
<h3 class="panel-title">Certificates Signature request list</h3>
<div class="pull-right">
<span class="clickable filter" data-toggle="tooltip" title="Toggle table filter" data-container="body">
<i class="glyphicon glyphicon-filter"></i>
</span>
</div>
</div>
<div class="panel-body">
<input type="text" class="form-control" id="cacert-table-filter" data-action="filter" data-filters="#cacert-table" placeholder="Filter Columns" />
</div>
<table class="table table-hover" id="cacert-table">
<thead>
<tr>
<th class="hidden-xs">#</th>
<th class="hidden-xs">CSR ID</th>
<th class="hidden-xs">CRT ID</th>
<th>Common Name</th>
<th class="hidden-xs">Request Date</th>
<th></th>
</tr>
</thead>
<tbody>
{% for csr in data_list -%}
<tr>
<td class="hidden-xs">{{ csr['index'] }}</td>
<td class="hidden-xs">{{ csr['csr_id'] }}</td>
<td class="hidden-xs">{{ csr['crt_id'] }}</td>
<td>{{ csr['common_name'] }}</td>
<td class="hidden-xs">{{ csr['creation_date'] }}</td>
<td>
<div class="btn-group">
<button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
Action <span class="caret"></span>
</button>
<ul class="dropdown-menu dropdown-menu-right">
<li><a href="/admin/signcert?csr_id={{ csr['csr_id'] }}"><i class="fa fa-check-square" aria-hidden="true"></i> Sign</a></li>
<li><a href="/csr/{{ csr['csr_id'] }}"><i class="fa fa-eye" aria-hidden="true"></i> Download</a></li>
<li role="separator" class="divider"></li>
<li><a href="/admin/deletecsr?csr_id={{ csr['csr_id'] }}"><i class="fa fa-times" aria-hidden="true"></i> Delete</a></li>
</ul>
</div>
</td>
</tr>
{% endfor -%}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}
\ No newline at end of file
{% extends "layout.html" %}
{% block content %}
<div class="page-header">
<h1>User account information</h1>
</div>
<div style="padding: 15px">
<p>
<a class="btn btn-default" href="{{ url_for('user.change_password') }}" role="button" title="Download file">
<i class="fa fa-pencil-square-o" aria-hidden="true"></i> Change password</a>
</p>
{% from "macros.html" import render_field, render_submit_field %}
<form action="" method="POST" class="form" role="form">
<div class="row">
<div class="col-sm-6 col-md-5 col-lg-4">
{{ form.hidden_tag() }}
{{ render_field(form.username, tabindex=230, readonly=true) }}
{{ render_field(form.first_name, tabindex=240) }}
{{ render_field(form.last_name, tabindex=250) }}
{{ render_field(form.email, tabindex=260) }}
{{ render_submit_field(form.submit, tabindex=280) }}
</div>
</div>
</form>
</div>
{% endblock %}
\ No newline at end of file
{% extends "layout.html" %}
{% block content %}
<div class="page-header">
<h1>View {{ cert_type }} Details <small></small></h1>
</div>
<div class="row">
<div class="col-md-12">
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">{{ name |safe }}</h3>
</div>
<div class="panel-body">
<pre>{{ content }}</pre>
</div>
</div>
</div>
</div>
{% endblock %}
\ No newline at end of file
{% extends "layout.html" %}
{% block content %}
<div class="page-header">
<h1>Certificate Authority <small>view logs content</small></h1>
</div>
<div class="row">
<div class="col-md-12">
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">{{ filename }}</h3>
</div>
<div class="panel-body">
<p>
<a class="btn btn-default" href="/admin/view_logs?full=true" role="button" title="Download file">
<i class="fa fa-file-text" aria-hidden="true"></i> Full log</a>
<a class="btn btn-default" href="/admin/view_logs" role="button" title="Download file">
<i class="fa fa-file-text" aria-hidden="true"></i> Tail log</a>
</p>
<pre style="max-height: 600px;">{{ content }}</pre>
</div>
</div>
</div>
</div>
{% endblock %}
\ No newline at end of file
# This file is part of caucase
# Copyright (C) 2017 Nexedi
# 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
Test suite
"""
from __future__ import absolute_import
from cStringIO import StringIO
import datetime
import errno
import glob
import ipaddress
import os
import multiprocessing
import random
import shutil
import socket
import sqlite3
import sys
import tempfile
import time
import urlparse
import unittest
from cryptography import x509
from cryptography.hazmat.backends import default_backend
from caucase import cli
from caucase.client import CaucaseError
from caucase import http
from caucase import utils
from caucase import exceptions
from caucase import wsgi
from caucase.storage import SQLite3Storage
_cryptography_backend = default_backend()
NOT_CAUCASE_OID = '2.25.285541874270823339875695650038637483518'
def canConnect(address):
"""
Returns True if a connection can be established to given address, False
otherwise.
"""
try:
socket.create_connection(address)
except socket.error, e:
if e.errno == errno.ECONNREFUSED:
return False
raise
return True
def retry(callback, try_count=10, try_delay=0.1):
"""
Poll <callback> every <try_delay> for <try_count> times or until it returns
a true value.
Always returns the value returned by latest callback invocation.
"""
for _ in xrange(try_count):
result = callback()
if result:
return result
time.sleep(try_delay)
class CaucaseTest(unittest.TestCase):
"""
Test a complete caucase setup: spawn a caucase-http server on CAUCASE_NETLOC
and use caucase-cli to access it.
"""
_server = None
def setUp(self):
"""
Prepare test data directory and file paths, and start caucased as most
tests will need to interact with it.
"""
self._data_dir = data_dir = tempfile.mkdtemp(prefix='caucase_test_')
self._client_dir = client_dir = os.path.join(data_dir, 'client')
os.mkdir(client_dir)
# pylint: disable=bad-whitespace
self._client_ca_crt = os.path.join(client_dir, 'cas.crt.pem')
self._client_user_ca_crt = os.path.join(client_dir, 'cau.crt.pem')
self._client_crl = os.path.join(client_dir, 'cas.crl.pem')
self._client_user_crl = os.path.join(client_dir, 'cau.crl.pem')
# pylint: enable=bad-whitespace
self._server_dir = server_dir = os.path.join(data_dir, 'server')
os.mkdir(server_dir)
# pylint: disable=bad-whitespace
self._server_db = os.path.join(server_dir, 'caucase.sqlite')
self._server_key = os.path.join(server_dir, 'server.key.pem')
self._server_backup_path = os.path.join(server_dir, 'backup')
# pylint: enable=bad-whitespace
os.mkdir(self._server_backup_path)
self._server_netloc = netloc = os.getenv('CAUCASE_NETLOC', 'localhost:8000')
self._caucase_url = 'http://' + netloc
parsed_url = urlparse.urlparse(self._caucase_url)
self.assertFalse(
canConnect((parsed_url.hostname, parsed_url.port)),
'Something else is already listening on %r, define CAUCASE_NETLOC '
'environment variable with a different ip/port' % (netloc, ),
)
self._startServer()
def tearDown(self):
"""
Stop any running caucased and delete all test data files.
"""
if self._server.is_alive():
self._stopServer()
else:
print 'Server exited with status: %s' % (self._server.exitcode, )
shutil.rmtree(self._data_dir)
def _restoreServer(
self,
backup_path,
key_path,
new_csr_path,
new_key_path,
try_count=10,
):
"""
Start caucased in its special --restore-backup mode. It will exit once
done.
Returns its exit status.
"""
server = multiprocessing.Process(
target=http.main,
kwargs={
'argv': (
'--db', self._server_db,
'--server-key', self._server_key,
'--netloc', self._server_netloc,
#'--threshold', '31',
#'--key-len', '2048',
'--backup-directory', self._server_backup_path,
'--restore-backup',
backup_path,
key_path,
new_csr_path,
new_key_path,
),
}
)
server.daemon = True
server.start()
# Must exit after a (short) while
if not retry(lambda: not server.is_alive(), try_count=try_count):
raise AssertionError('Backup restoration took more than %i second' % (
try_count / 10,
))
return server.exitcode
def _startServer(self, *argv):
"""
Start caucased server
"""
self._server = server = multiprocessing.Process(
target=http.main,
kwargs={
'argv': (
'--db', self._server_db,
'--server-key', self._server_key,
'--netloc', self._server_netloc,
#'--threshold', '31',
#'--key-len', '2048',
) + argv,
}
)
server.daemon = True
server.start()
parsed_url = urlparse.urlparse(self._caucase_url)
if not retry(
lambda: canConnect((parsed_url.hostname, parsed_url.port)),
):
self._stopServer()
raise AssertionError('Could not connect to %r after 1 second' % (
self._caucase_url,
))
def _stopServer(self):
"""
Stop a running caucased server
"""
server = self._server
server.terminate()
server.join(.1)
if server.is_alive():
# Sometimes, server survives to a SIGTERM. Maybe an effect of it being
# multi-threaded, or something in python which would catch SystemExit ?
# It does typically succeed on second try, so just do that.
server.terminate()
server.join(.1)
if server.is_alive():
raise ValueError('pid %i does not wish to die' % (server.pid, ))
def _runClient(self, *argv):
"""
Run client with given arguments.
Returns stdout.
"""
orig_stdout = sys.stdout
sys.stdout = stdout = StringIO()
try:
cli.main(
argv=(
'--ca-url', self._caucase_url,
'--ca-crt', self._client_ca_crt,
'--user-ca-crt', self._client_user_ca_crt,
'--crl', self._client_crl,
'--user-crl', self._client_user_crl,
) + argv,
)
except SystemExit:
pass
finally:
sys.stdout = orig_stdout
return stdout.getvalue()
@staticmethod
def _setCertificateRemainingLifeTime(key, crt, delta):
"""
Re-sign <crt> with <key>, shifting both its not_valid_before and
not_valid_after dates so that its remaining validity period
becomes <delta> and its validity span stays unchanged.
"""
new_not_valid_after_date = datetime.datetime.utcnow() + delta
return x509.CertificateBuilder(
subject_name=crt.subject,
issuer_name=crt.issuer,
not_valid_before=new_not_valid_after_date - (
crt.not_valid_after - crt.not_valid_before
),
not_valid_after=new_not_valid_after_date,
serial_number=crt.serial_number,
public_key=crt.public_key(),
extensions=crt.extensions,
).sign(
private_key=key,
algorithm=crt.signature_hash_algorithm,
backend=_cryptography_backend,
)
def _setCACertificateRemainingLifeTime(self, mode, serial, delta):
"""
Find the CA certificate with <serial> in caucase <mode> ("service"
or "user") and pass it to _setCertificateRemainingLifeTime with <delta>.
"""
int(serial) # Must already be an integer
prefix = {
'user': 'cau',
'service': 'cas',
}[mode]
db = sqlite3.connect(self._server_db)
db.row_factory = sqlite3.Row
with db:
c = db.cursor()
c.execute(
'SELECT rowid, key, crt FROM ' + prefix + 'ca',
)
while True:
row = c.fetchone()
if row is None:
raise Exception('CA with serial %r not found' % (serial, ))
crt = utils.load_ca_certificate(row['crt'].encode('ascii'))
if crt.serial_number == serial:
new_crt = self._setCertificateRemainingLifeTime(
key=utils.load_privatekey(row['key'].encode('ascii')),
crt=crt,
delta=delta,
)
new_crt_pem = utils.dump_certificate(new_crt)
c.execute(
'UPDATE ' + prefix + 'ca SET '
'expiration_date=?, crt=? '
'WHERE rowid=?',
(
utils.datetime2timestamp(new_crt.not_valid_after),
new_crt_pem,
row['rowid'],
),
)
return new_crt_pem
def _getBaseName(self):
"""
Returns a random file name, prefixed by data directory.
"""
return os.path.join(
self._data_dir,
str(random.getrandbits(32)),
)
@staticmethod
def _createPrivateKey(basename, key_len=2048):
"""
Create a private key and store it to file.
"""
name = basename + '.key.pem'
assert not os.path.exists(name)
with open(name, 'w') as key_file:
key_file.write(utils.dump_privatekey(
utils.generatePrivateKey(key_len=key_len),
))
return name
@staticmethod
def _getBasicCSRBuilder():
"""
Initiate a minimal CSR builder.
"""
return x509.CertificateSigningRequestBuilder(
subject_name=x509.Name([
x509.NameAttribute(
oid=x509.oid.NameOID.COMMON_NAME,
value=u'test',
),
]),
)
@staticmethod
def _finalizeCSR(basename, key_path, csr_builder):
"""
Sign, serialise and store given CSR Builder to file.
"""
name = basename + '.csr.pem'
assert not os.path.exists(name)
with open(name, 'w') as csr_file:
csr_file.write(
utils.dump_certificate_request(
csr_builder.sign(
private_key=utils.load_privatekey(utils.getKey(key_path)),
algorithm=utils.DEFAULT_DIGEST_CLASS(),
backend=_cryptography_backend,
),
),
)
return name
def _createBasicCSR(self, basename, key_path):
"""
Creates a basic CSR and returns its path.
"""
return self._finalizeCSR(
basename,
key_path,
self._getBasicCSRBuilder(),
)
def _createFirstUser(self, add_extensions=False):
"""
Create first user, whose CSR is automatically signed.
"""
basename = self._getBaseName()
user_key_path = self._createPrivateKey(basename)
csr_builder = self._getBasicCSRBuilder()
if add_extensions:
csr_builder = csr_builder.add_extension(
x509.CertificatePolicies([
x509.PolicyInformation(
x509.oid.ObjectIdentifier(NOT_CAUCASE_OID),
None,
)
]),
critical=False,
)
csr_path = self._finalizeCSR(
basename,
user_key_path,
csr_builder,
)
out, = self._runClient(
'--mode', 'user',
'--send-csr', csr_path,
).splitlines()
csr_id, csr_path_out = out.split()
# Sanity check output
self.assertEqual(csr_path, csr_path_out)
int(csr_id)
self.assertRaises(TypeError, utils.getCert, user_key_path)
self._runClient(
'--mode', 'user',
'--get-crt', csr_id, user_key_path,
)
# Does not raise anymore, we have a certificate
utils.getCert(user_key_path)
return user_key_path
def _createAndApproveCertificate(self, user_key_path, mode):
"""
Create a CSR, submit it, approve it and retrieve the certificate.
"""
basename = self._getBaseName()
key_path = self._createPrivateKey(basename)
csr_path = self._createBasicCSR(basename, key_path)
out, = self._runClient(
'--mode', mode,
'--send-csr', csr_path,
).splitlines()
csr_id, csr_path_out = out.split()
# Sanity check output
self.assertEqual(csr_path, csr_path_out)
int(csr_id)
self.assertRaises(TypeError, utils.getCert, key_path)
out = self._runClient(
'--mode', mode,
'--get-crt', csr_id, key_path,
).splitlines()
self.assertRaises(TypeError, utils.getCert, key_path)
self.assertEqual([csr_id + ' CSR still pending'], out)
csr2_path = csr_path + '.2'
self._runClient(
'--mode', mode,
'--get-csr', csr_id, csr2_path,
)
self.assertEqual(open(csr_path).read(), open(csr2_path).read())
# Sign using user cert
# Note: assuming user does not know the csr_id and keeps their own copy of
# issued certificates.
out = self._runClient(
'--mode', mode,
'--user-key', user_key_path,
'--list-csr',
).splitlines()
self.assertEqual([csr_id], [x.split(None, 1)[0] for x in out[2:-1]])
self.assertRaises(
CaucaseError,
self._runClient,
'--mode', mode,
'--user-key', user_key_path,
'--sign-csr', str(int(csr_id) + 1),
)
out = self._runClient(
'--mode', mode,
'--user-key', user_key_path,
'--sign-csr', csr_id,
)
self.assertRaises(
CaucaseError,
self._runClient,
'--mode', mode,
'--user-key', user_key_path,
'--sign-csr', csr_id,
)
# Now requester can get their certificate
out, = self._runClient(
'--mode', mode,
'--get-crt', csr_id, key_path,
).splitlines()
# Does not raise anymore, we have a certificate
utils.getCert(user_key_path)
return key_path
def testBasicUsage(self):
"""
Everybody plays by the rules (which includes trying to access when
revoked).
"""
self.assertFalse(os.path.exists(self._client_ca_crt))
self.assertFalse(os.path.exists(self._client_crl))
self.assertFalse(os.path.exists(self._client_user_ca_crt))
self.assertFalse(os.path.exists(self._client_user_crl))
# Running client creates CAS files (service CA & service CRL)
self._runClient()
self.assertTrue(os.path.exists(self._client_ca_crt))
self.assertTrue(os.path.exists(self._client_crl))
self.assertFalse(os.path.exists(self._client_user_ca_crt))
self.assertFalse(os.path.exists(self._client_user_crl))
# Running in "user" mode also created the CAU CA, but not the CAU CRL
self._runClient('--mode', 'user')
self.assertTrue(os.path.exists(self._client_ca_crt))
self.assertTrue(os.path.exists(self._client_crl))
self.assertTrue(os.path.exists(self._client_user_ca_crt))
self.assertFalse(os.path.exists(self._client_user_crl))
cas_crt_list = [
utils.load_ca_certificate(x)
for x in utils.getCertList(self._client_ca_crt)
]
cau_crt_list = [
utils.load_ca_certificate(x)
for x in utils.getCertList(self._client_user_ca_crt)
]
# No CA renewal happened yet
self.assertEqual(len(cas_crt_list), 1)
self.assertEqual(len(cau_crt_list), 1)
# Get a user key pair
user_key_path = self._createFirstUser()
# It must have been auto-signed
self.assertTrue(utils.isCertificateAutoSigned(utils.load_certificate(
# utils.getCert(user_key_path) does not raise anymore
utils.getCert(user_key_path),
cau_crt_list,
None,
)))
# Get a not-auto-approved service crt (the first auto-approved one was for
# the http server itself)
service_key = self._createAndApproveCertificate(
user_key_path,
'service',
)
self.assertFalse(utils.isCertificateAutoSigned(utils.load_certificate(
utils.getCert(service_key),
cas_crt_list,
None,
)))
# Get a not-auto-approved user crt
user2_key_path = self._createAndApproveCertificate(
user_key_path,
'user',
)
self.assertFalse(utils.isCertificateAutoSigned(utils.load_certificate(
utils.getCert(user2_key_path),
cau_crt_list,
None,
)))
# It can itself sign certificates...
service2_key_path = self._createAndApproveCertificate(
user2_key_path,
'service',
)
user3_key_path = self._createAndApproveCertificate(
user2_key_path,
'user',
)
self._runClient(
'--user-key', user2_key_path,
'--list-csr',
)
self._runClient(
'--mode', 'user',
'--user-key', user2_key_path,
'--list-csr',
)
# ...until it gets revoked
self._runClient(
'--user-key', user_key_path,
'--mode', 'user',
'--revoke-other-crt', user2_key_path,
'--update-user',
)
self.assertRaises(
CaucaseError,
self._createAndApproveCertificate,
user2_key_path,
'service',
)
self.assertRaises(
CaucaseError,
self._createAndApproveCertificate,
user2_key_path,
'user',
)
self.assertRaises(
CaucaseError,
self._runClient,
'--user-key', user2_key_path,
'--list-csr',
)
self.assertRaises(
CaucaseError,
self._runClient,
'--mode', 'user',
'--user-key', user2_key_path,
'--list-csr',
)
# But the user it approved still works...
self._runClient(
'--user-key', user3_key_path,
'--list-csr',
)
# ...until it revokes itself
self._runClient(
'--mode', 'user',
'--user-key', user3_key_path,
'--revoke-serial', str(
utils.load_certificate(
utils.getCert(user3_key_path),
cau_crt_list,
None,
).serial_number,
)
)
self.assertRaises(
CaucaseError,
self._runClient,
'--user-key', user3_key_path,
'--list-csr',
)
# And the service it approved works too
service2_crt_before, service2_key_before, _ = utils.getKeyPair(
service2_key_path,
)
self._runClient(
# 100 days is longer than certificate life, so it will be immediately
# renewed.
'--threshold', '100',
'--renew-crt', service2_key_path, '',
)
service2_crt_after, service2_key_after, _ = utils.getKeyPair(
service2_key_path,
)
# Certificate and key were renewed...
self.assertNotEqual(service2_crt_before, service2_crt_after)
self.assertNotEqual(service2_key_before, service2_key_after)
# ...and not just swapped
self.assertNotEqual(service2_crt_before, service2_key_after)
self.assertNotEqual(service2_key_before, service2_crt_after)
# It can revoke itself...
self._runClient(
'--revoke-crt', service2_key_path, '',
)
# ...and then it cannot renew itself any more...
self.assertRaises(
CaucaseError,
self._runClient,
'--threshold', '100',
'--renew-crt', service2_key_path, '',
)
service2_crt_after2, service2_key_after2, _ = utils.getKeyPair(
service2_key_path,
)
# and crt & key did not change
self.assertEqual(service2_crt_after, service2_crt_after2)
self.assertEqual(service2_key_after, service2_key_after2)
# revoking again one's own certificate fails
self.assertRaises(
CaucaseError,
self._runClient,
'--revoke-crt', service2_key_path, '',
)
# as does revoking with an authenticated user
self.assertRaises(
CaucaseError,
self._runClient,
'--user-key', user_key_path,
'--revoke-other-crt', service2_key_path,
)
# and revoking by serial
self.assertRaises(
CaucaseError,
self._runClient,
'--user-key', user_key_path,
'--revoke-serial', str(
utils.load_certificate(
utils.getCert(service2_key_path),
cas_crt_list,
None,
).serial_number,
),
)
# Rejecting a CSR
basename = self._getBaseName()
key_path = self._createPrivateKey(basename)
csr_path = self._createBasicCSR(basename, key_path)
out, = self._runClient(
'--send-csr', csr_path,
).splitlines()
csr_id, csr_path_out = out.split()
# Sanity check output
self.assertEqual(csr_path, csr_path_out)
int(csr_id)
self.assertRaises(TypeError, utils.getCert, key_path)
out = self._runClient(
'--get-crt', csr_id, key_path,
).splitlines()
self.assertRaises(TypeError, utils.getCert, key_path)
self.assertEqual([csr_id + ' CSR still pending'], out)
out = self._runClient(
'--user-key', user_key_path,
'--reject-csr', csr_id,
).splitlines()
# Re-rejecting fails
self.assertRaises(
CaucaseError,
self._runClient,
'--user-key', user_key_path,
'--reject-csr', csr_id,
)
# like rejecting a non-existing crt
self.assertRaises(
CaucaseError,
self._runClient,
'--user-key', user_key_path,
'--reject-csr', str(int(csr_id) + 1),
)
out = self._runClient(
'--get-crt', csr_id, key_path,
).splitlines()
self.assertRaises(TypeError, utils.getCert, key_path)
self.assertEqual([
csr_id + ' not found - either csr id has a typo or CSR was rejected'
], out)
def testUpdateUser(self):
"""
Verify that CAU certificate and revocation list are created when the
(rarely needed) --update-user option is given.
"""
self.assertFalse(os.path.exists(self._client_ca_crt))
self.assertFalse(os.path.exists(self._client_crl))
self.assertFalse(os.path.exists(self._client_user_ca_crt))
self.assertFalse(os.path.exists(self._client_user_crl))
self._runClient('--update-user')
self.assertTrue(os.path.exists(self._client_ca_crt))
self.assertTrue(os.path.exists(self._client_crl))
self.assertTrue(os.path.exists(self._client_user_ca_crt))
self.assertTrue(os.path.exists(self._client_user_crl))
def testMaxCSR(self):
"""
Verify that the number of pending CSR is properly constrained.
"""
csr_list = []
def assertCanSend(count):
"""
Check that caucased accepts <count> CSR, and rejects the next one.
Appends the data of created CSRs (csr_id and csr_path) to csr_list.
"""
for _ in xrange(count):
basename = self._getBaseName()
csr_path = self._createBasicCSR(
basename,
self._createPrivateKey(basename),
)
out, = self._runClient('--send-csr', csr_path).splitlines()
csr_id, _ = out.split()
csr_list.append((csr_id, csr_path))
basename = self._getBaseName()
bad_csr_path = self._createBasicCSR(
basename,
self._createPrivateKey(basename),
)
self.assertRaises(
CaucaseError,
self._runClient,
'--send-csr',
bad_csr_path,
)
user_key_path = self._createFirstUser()
self._stopServer()
self._startServer(
'--service-max-csr', '5',
)
assertCanSend(5)
# But resubmitting one of the accepted ones is still fine
_, csr_path = csr_list[0]
self._runClient('--send-csr', csr_path)
# Accepted certificates do not count towards the total, even if not
# retrieved by owner
csr_id, csr_path = csr_list.pop()
self._runClient(
'--user-key', user_key_path,
'--sign-csr', csr_id,
)
assertCanSend(1)
# Rejected certificates do not count towards the total.
csr_id, _ = csr_list.pop()
self._runClient(
'--user-key', user_key_path,
'--reject-csr', csr_id,
)
assertCanSend(1)
def testLockAutoSignAmount(self):
"""
Verify that auto-approve limit freezing works.
"""
self._stopServer()
self._startServer(
'--user-auto-approve-count', '2',
'--lock-auto-approve-count',
)
self._stopServer()
self._startServer(
'--user-auto-approve-count', '3',
)
self._createFirstUser()
self._createFirstUser()
self.assertRaises(TypeError, self._createFirstUser)
self._stopServer()
self._startServer(
'--user-auto-approve-count', '3',
'--lock-auto-approve-count',
)
self.assertRaises(TypeError, self._createFirstUser)
def testCSRFiltering(self):
"""
Verify that requester cannot get any extension or extension value they
ask for. Caucase has to protect itself to be trustworthy, but also to let
some liberty to requester.
"""
def checkCRT(key_path):
"""
Verify key_path to contain exactly one certificate, itself containing
all expected extensions.
"""
crt = utils.load_certificate(
utils.getCert(key_path),
cas_crt_list,
None,
)
# CA-only extension, must not be present in certificate
self.assertRaises(
x509.ExtensionNotFound,
crt.extensions.get_extension_for_class,
x509.NameConstraints,
)
for expected_value in [
expected_key_usage,
expected_extended_usage,
expected_alt_name,
expected_policies,
expected_basic_constraints,
]:
extension = crt.extensions.get_extension_for_class(
expected_value.__class__,
)
self.assertEqual(
extension.value,
expected_value,
)
self.assertTrue(extension.critical)
requested_key_usage = x509.KeyUsage(
# pylint: disable=bad-whitespace
digital_signature =True,
content_commitment=True,
key_encipherment =True,
data_encipherment =True,
key_agreement =True,
key_cert_sign =True,
crl_sign =True,
encipher_only =True,
decipher_only =False,
# pylint: enable=bad-whitespace
)
expected_key_usage = x509.KeyUsage(
# pylint: disable=bad-whitespace
digital_signature =True,
content_commitment=True,
key_encipherment =True,
data_encipherment =True,
key_agreement =True,
key_cert_sign =False,
crl_sign =False,
encipher_only =True,
decipher_only =False,
# pylint: enable=bad-whitespace
)
requested_extended_usage = x509.ExtendedKeyUsage([
x509.oid.ExtendedKeyUsageOID.OCSP_SIGNING,
x509.oid.ExtendedKeyUsageOID.CLIENT_AUTH,
])
expected_extended_usage = x509.ExtendedKeyUsage([
x509.oid.ExtendedKeyUsageOID.CLIENT_AUTH,
])
requested_alt_name = expected_alt_name = x509.SubjectAlternativeName([
x509.RFC822Name('nobody@example.com'),
x509.DNSName('example.com'),
x509.UniformResourceIdentifier('https://example.com/a/b/c'),
x509.IPAddress(ipaddress.IPv4Address(u'127.0.0.1')),
x509.IPAddress(ipaddress.IPv6Address(u'::1')),
x509.IPAddress(ipaddress.IPv4Network(u'127.0.0.0/8')),
x509.IPAddress(ipaddress.IPv6Network(u'::/64')),
])
requested_policies = x509.CertificatePolicies([
x509.PolicyInformation(
x509.oid.ObjectIdentifier(utils.CAUCASE_OID_RESERVED),
None,
),
x509.PolicyInformation(
x509.oid.ObjectIdentifier(NOT_CAUCASE_OID),
None,
),
])
expected_policies = x509.CertificatePolicies([
x509.PolicyInformation(
x509.oid.ObjectIdentifier(NOT_CAUCASE_OID),
None,
),
])
requested_basic_constraints = x509.BasicConstraints(
ca=True,
path_length=42,
)
expected_basic_constraints = x509.BasicConstraints(
ca=False,
path_length=None,
)
# Check stored CSR filtering
user_key_path = self._createFirstUser(add_extensions=True)
basename = self._getBaseName()
key_path = self._createPrivateKey(basename)
requested_csr_path = self._finalizeCSR(
basename,
key_path,
self._getBasicCSRBuilder(
).add_extension(requested_key_usage, critical=True,
).add_extension(requested_extended_usage, critical=True,
).add_extension(requested_alt_name, critical=True,
).add_extension(requested_policies, critical=True,
).add_extension(requested_basic_constraints, critical=True,
).add_extension(
x509.NameConstraints([x509.DNSName('com')], None),
critical=True,
),
)
out, = self._runClient(
'--send-csr', requested_csr_path,
).splitlines()
csr_id, _ = out.split()
int(csr_id)
self._runClient(
'--user-key', user_key_path,
'--sign-csr', csr_id,
)
self._runClient(
'--get-crt', csr_id, key_path,
)
cas_crt_list = [
utils.load_ca_certificate(x)
for x in utils.getCertList(self._client_ca_crt)
]
cau_crt_list = [
utils.load_ca_certificate(x)
for x in utils.getCertList(self._client_user_ca_crt)
]
checkCRT(key_path)
# Check renewed CRT filtering does not alter clean signed certificate
# content (especially, caucase auto-signed flag must not appear).
before_key = open(key_path).read()
self._runClient(
'--threshold', '100',
'--renew-crt', key_path, '',
)
after_key = open(key_path).read()
assert before_key != after_key
checkCRT(key_path)
# Check content of auto-issued user certificate
user_crt = utils.load_certificate(
utils.getCert(user_key_path),
cau_crt_list,
None,
)
user_certificate_policies = user_crt.extensions.get_extension_for_class(
x509.CertificatePolicies,
)
self.assertEqual(
user_certificate_policies.value,
x509.CertificatePolicies([
x509.PolicyInformation(
x509.oid.ObjectIdentifier(NOT_CAUCASE_OID),
None,
),
utils.CAUCASE_POLICY_INFORMATION_AUTO_SIGNED,
]),
)
self.assertFalse(user_certificate_policies.critical)
# Check template CSR: must be taken into account, but it also gets
# filtered.
basename2 = self._getBaseName()
key_path2 = self._createPrivateKey(basename2)
out, = self._runClient(
'--send-csr', self._finalizeCSR(
basename2,
key_path2,
self._getBasicCSRBuilder(),
),
).splitlines()
csr_id, _ = out.split()
int(csr_id)
self._runClient(
'--user-key', user_key_path,
'--sign-csr-with', csr_id, requested_csr_path,
)
self._runClient(
'--get-crt', csr_id, key_path2,
)
checkCRT(key_path2)
def testCACertRenewal(self):
"""
Exercise CA certificate rollout procedure.
"""
user_key_path = self._createFirstUser()
cau_crt, = [
utils.load_ca_certificate(x)
for x in utils.getCertList(self._client_user_ca_crt)
]
self._stopServer()
# CA expires in 100 days: longer than one certificate life,
# but shorter than two. A new CA must be generated and distributed,
# but not used for new signatures yet.
new_cau_crt_pem = self._setCACertificateRemainingLifeTime(
'user',
cau_crt.serial_number,
datetime.timedelta(100, 0),
)
# As we will use this crt as trust anchor, we must make the client believe
# it knew it all along.
with open(self._client_user_ca_crt, 'w') as client_user_ca_crt_file:
client_user_ca_crt_file.write(new_cau_crt_pem)
self._startServer()
new_user_key = self._createAndApproveCertificate(
user_key_path,
'user',
)
# Must not raise: we are signed with the "old" ca.
utils.load_certificate(
utils.getCert(new_user_key),
[cau_crt],
None,
)
# We must now know the new CA
cau_crt_list = [
utils.load_ca_certificate(x)
for x in utils.getCertList(self._client_user_ca_crt)
]
new_cau_crt, = [
x for x in cau_crt_list
if x.serial_number != cau_crt.serial_number
]
self._stopServer()
# New CA now exists for 100 days: longer than one certificate life.
# It may (must) be used for new signatures.
self._setCACertificateRemainingLifeTime(
'user',
new_cau_crt.serial_number,
new_cau_crt.not_valid_after - new_cau_crt.not_valid_before -
datetime.timedelta(100, 0),
)
self._startServer()
self._runClient(
'--mode', 'user',
# 100 days is longer than certificate life, so it will be immediately
# renewed.
'--threshold', '100',
'--renew-crt', new_user_key, '',
)
self.assertRaises(
exceptions.CertificateVerificationError,
utils.load_certificate,
utils.getCert(new_user_key),
[cau_crt],
None,
)
utils.load_certificate(
utils.getCert(new_user_key),
cau_crt_list,
None,
)
def testCaucasedCertRenewal(self):
"""
Exercise caucased internal certificate renewal procedure.
"""
user_key_path = self._createFirstUser()
self._stopServer()
# If server certificate is deleted, it gets re-created, even it CAS
# reached its certificate auto-approval limit.
os.unlink(self._server_key)
self._startServer()
if not retry(lambda: os.path.exists(self._server_key)):
raise AssertionError('%r was not recreated within 1 second' % (
self._server_key,
))
# But user still trusts the server
self._runClient(
'--mode', 'user',
# 100 days is longer than certificate life, so it will be immediately
# renewed.
'--threshold', '100',
'--renew-crt', user_key_path, '',
)
# Server certificate will expire in 20 days, the key must be renewed
# (but we have to peek at CAS private key, cover your eyes)
(cas_key, ), = sqlite3.connect(
self._server_db,
).cursor().execute(
'SELECT key FROM casca',
).fetchall()
self._stopServer()
crt_pem, key_pem, _ = reference_key_pair_result = utils.getKeyPair(
self._server_key,
)
with open(self._server_key, 'w') as server_key_file:
server_key_file.write(key_pem)
server_key_file.write(utils.dump_certificate(
self._setCertificateRemainingLifeTime(
key=utils.load_privatekey(cas_key.encode('ascii')),
crt=utils.load_certificate(
crt_pem,
[
utils.load_ca_certificate(x)
for x in utils.getCertList(self._client_ca_crt)
],
None,
),
delta=datetime.timedelta(20, 0)
)
))
self._startServer()
if not retry(
lambda: utils.getKeyPair(self._server_key) != reference_key_pair_result,
):
raise AssertionError('Server did not renew its key pair within 1 second')
# But user still trusts the server
self._runClient(
'--mode', 'user',
# 100 days is longer than certificate life, so it will be immediately
# renewed.
'--threshold', '100',
'--renew-crt', user_key_path, '',
)
def testWSGI(self):
"""
Test wsgi class reaction to malformed requests.
For tests which are not accessible through the client module (as it tries
to only produce valid requests).
"""
self._runClient('--mode', 'user', '--update-user')
cau_list = [
utils.load_ca_certificate(x)
for x in utils.getCertList(self._client_user_ca_crt)
]
cau_crl = open(self._client_user_crl).read()
class DummyCAU(object):
"""
Mock CAU.
"""
def getCACertificateList(self):
"""
Return cau ca list.
"""
return cau_list
def getCertificateRevocationList(self):
"""
Return cau crl.
"""
return cau_crl
@staticmethod
def appendCertificateSigningRequest(_):
"""
Raise to exercise the "unexpected exception" code path in WSGI.
"""
raise ValueError('Some generic exception')
application = wsgi.Application(DummyCAU(), None)
def request(environ):
"""
Non-standard shorthand for invoking the WSGI application.
"""
start_response_list = []
body = list(application(
environ,
lambda status, header_list: start_response_list.append(
(status, header_list),
),
))
# pylint: disable=unbalanced-tuple-unpacking
(status, header_list), = start_response_list
# pylint: enable=unbalanced-tuple-unpacking
status, reason = status.split(' ', 1)
return int(status), reason, header_list, ''.join(body)
UNAUTHORISED_STATUS = 404
self.assertEqual(request({
'PATH_INFO': '/',
})[0], 404)
self.assertEqual(request({
'PATH_INFO': '/foo/bar',
})[0], 404)
self.assertEqual(request({
'PATH_INFO': '/cau',
})[0], 404)
self.assertEqual(request({
'PATH_INFO': '/cau/__init__',
})[0], 404)
self.assertEqual(request({
'PATH_INFO': '/cau/does_not_exist',
})[0], 404)
self.assertEqual(request({
'PATH_INFO': '/cau/crl/123',
'REQUEST_METHOD': 'GET',
})[0], 404)
self.assertEqual(request({
'PATH_INFO': '/cau/crl',
'REQUEST_METHOD': 'PUT',
})[0], 405)
self.assertEqual(request({
'PATH_INFO': '/cau/csr/123/456',
'REQUEST_METHOD': 'GET',
})[0], 404)
self.assertEqual(request({
'PATH_INFO': '/cau/csr/123',
'REQUEST_METHOD': 'POST',
})[0], 405)
self.assertEqual(request({
'PATH_INFO': '/cau/csr/a',
'REQUEST_METHOD': 'GET',
})[0], 400)
self.assertEqual(request({
'PATH_INFO': '/cau/csr',
'REQUEST_METHOD': 'GET',
})[0], UNAUTHORISED_STATUS)
self.assertEqual(request({
'PATH_INFO': '/cau/csr/123',
'REQUEST_METHOD': 'PUT',
})[0], 404)
self.assertEqual(request({
'PATH_INFO': '/cau/csr',
'REQUEST_METHOD': 'PUT',
'wsgi.input': StringIO(),
})[0], 500)
self.assertEqual(request({
'PATH_INFO': '/cau/csr',
'REQUEST_METHOD': 'DELETE',
})[0], 404)
self.assertEqual(request({
'PATH_INFO': '/cau/csr/123/456',
'REQUEST_METHOD': 'DELETE',
})[0], 404)
self.assertEqual(request({
'PATH_INFO': '/cau/csr/123',
'REQUEST_METHOD': 'DELETE',
})[0], UNAUTHORISED_STATUS)
self.assertEqual(request({
'PATH_INFO': '/cau/crt',
'REQUEST_METHOD': 'GET',
})[0], 404)
self.assertEqual(request({
'PATH_INFO': '/cau/crt/123/456',
'REQUEST_METHOD': 'GET',
})[0], 404)
self.assertEqual(request({
'PATH_INFO': '/cau/crt/a',
'REQUEST_METHOD': 'GET',
})[0], 400)
self.assertEqual(request({
'PATH_INFO': '/cau/crt/renew',
'REQUEST_METHOD': 'PUT',
'CONTENT_TYPE': 'text/plain',
})[0], 400)
self.assertEqual(request({
'PATH_INFO': '/cau/crt/renew',
'REQUEST_METHOD': 'PUT',
'CONTENT_TYPE': 'application/json',
'CONTENT_LENGTH': 'a',
})[0], 400)
self.assertEqual(request({
'PATH_INFO': '/cau/crt/renew',
'REQUEST_METHOD': 'PUT',
'CONTENT_TYPE': 'application/json',
'CONTENT_LENGTH': str(10 * 1024 * 1024 + 1),
})[0], 413)
self.assertEqual(request({
'PATH_INFO': '/cau/crt/renew',
'REQUEST_METHOD': 'PUT',
'CONTENT_TYPE': 'application/json',
'wsgi.input': StringIO('{'),
})[0], 400)
self.assertEqual(request({
'PATH_INFO': '/cau/crt/revoke',
'REQUEST_METHOD': 'PUT',
'CONTENT_TYPE': 'application/json',
'wsgi.input': StringIO('{"digest": null}'),
})[0], UNAUTHORISED_STATUS)
self.assertEqual(request({
'PATH_INFO': '/cau/crt/a',
'REQUEST_METHOD': 'PUT',
'CONTENT_TYPE': 'text/plain',
'wsgi.input': StringIO(''),
})[0], 400)
self.assertEqual(request({
'PATH_INFO': '/cau/crt/123',
'REQUEST_METHOD': 'PUT',
'CONTENT_TYPE': 'text/plain',
'wsgi.input': StringIO(''),
})[0], UNAUTHORISED_STATUS)
self.assertEqual(request({
'PATH_INFO': '/cau/crt/123',
'REQUEST_METHOD': 'PUT',
'CONTENT_TYPE': 'text/plain',
'wsgi.input': StringIO('foo'),
})[0], 400)
self.assertEqual(request({
'PATH_INFO': '/cau/crt/123',
'REQUEST_METHOD': 'POST',
})[0], 405)
def testProbe(self):
"""
Exercise caucase-probe command.
"""
cli.probe([self._caucase_url])
def testBackup(self):
"""
Exercise backup generation and restoration.
"""
backup_glob = os.path.join(self._server_backup_path, '*.sql.caucased')
user_key_path = self._createFirstUser()
user2_key_path = self._createAndApproveCertificate(
user_key_path,
'user',
)
# user2 sacrifice their private key, and prepare its replacement
basename = self._getBaseName()
user2_new_key_path = self._createPrivateKey(basename)
user2_new_csr_path = self._createBasicCSR(basename, user2_new_key_path)
# Restart caucased to not have to wait for next backup deadline
self._stopServer()
# Note: backup could have triggered between first and second user's key
# creation. We need it to be signed by both keys, so delete any backup file
# which would exist at this point.
for backup_path in glob.glob(backup_glob):
os.unlink(backup_path)
before_backup = list(SQLite3Storage(
self._server_db,
table_prefix='cau',
).dumpIterator())
self._startServer('--backup-directory', self._server_backup_path)
backup_path_list = retry(lambda: glob.glob(backup_glob))
if not backup_path_list:
raise AssertionError('Backup file not created after 1 second')
backup_path, = backup_path_list
self._stopServer()
# Server must refuse to restore if the database still exists
self.assertNotEqual(
self._restoreServer(
backup_path,
user2_key_path,
user2_new_csr_path,
user2_new_key_path,
),
0,
)
os.unlink(self._server_db)
os.unlink(self._server_key)
# XXX: just for the coverage... Should check output
cli.key_id([
'--private-key', user_key_path, user2_key_path,
'--backup', backup_path,
])
self.assertEqual(
self._restoreServer(
backup_path,
user2_key_path,
user2_new_csr_path,
user2_new_key_path,
),
0,
)
after_restore = list(SQLite3Storage(
self._server_db,
table_prefix='cau',
).dumpIterator())
CRL_INSERT = 'INSERT INTO "caucrl" '
CRT_INSERT = 'INSERT INTO "caucrt" '
REV_INSERT = 'INSERT INTO "caurevoked" '
def filterBackup(backup, received_csr, expect_rev):
"""
Remove all lines which are know to differ between original batabase and
post-restoration database, so the rest (which must be the majority of the
database) can be tested to be equal.
"""
rev_found = not expect_rev
new_backup = []
crt_list = []
for row in backup:
if (
row == received_csr
) or row.startswith(CRL_INSERT):
continue
if row.startswith(CRT_INSERT):
crt_list.append(row)
continue
if row.startswith(REV_INSERT):
if rev_found:
raise AssertionError('Unexpected revocation found')
continue
new_backup.append(row)
return new_backup, crt_list
before_backup, before_crt_list = filterBackup(
before_backup,
'INSERT INTO "caucounter" VALUES(\'received_csr\',2);\x00',
False,
)
after_restore, after_crt_list = filterBackup(
after_restore,
'INSERT INTO "caucounter" VALUES(\'received_csr\',3);\x00',
True,
)
self.assertEqual(
len(set(after_crt_list).difference(before_crt_list)),
1,
)
self.assertEqual(
len(set(before_crt_list).difference(after_crt_list)),
0,
)
self.assertItemsEqual(before_backup, after_restore)
self._startServer('--backup-directory', self._server_backup_path)
# user2 got a new certificate matching their new key
utils.getKeyPair(user2_new_key_path)
# And user 1 must still work without key change
self._runClient(
'--user-key', user_key_path,
'--list-csr',
)
# Another backup can happen after restoration
self._stopServer()
for backup_path in glob.glob(backup_glob):
os.unlink(backup_path)
self._startServer('--backup-directory', self._server_backup_path)
backup_path_list = retry(lambda: glob.glob(backup_glob))
if not backup_path_list:
raise AssertionError('Backup file not created after 1 second')
backup_path, = glob.glob(backup_glob)
cli.key_id([
'--backup', backup_path,
])
# Now, push a lot of data to exercise chunked checksum in backup &
# restoration code
self._stopServer()
for backup_path in glob.glob(backup_glob):
os.unlink(backup_path)
db = sqlite3.connect(self._server_db)
db.row_factory = sqlite3.Row
with db:
c = db.cursor()
c.execute('CREATE TABLE bloat (bloat TEXT)')
bloat_query = 'INSERT INTO bloat VALUES (?)'
bloat_value = ('bloat' * 10240, )
for _ in xrange(1024):
c.execute(bloat_query, bloat_value)
db.close()
del db
self._startServer('--backup-directory', self._server_backup_path)
backup_path_list = retry(lambda: glob.glob(backup_glob), try_count=20)
if not backup_path_list:
raise AssertionError('Backup file not created after 2 second')
backup_path, = glob.glob(backup_glob)
cli.key_id([
'--backup', backup_path,
])
self._stopServer()
os.unlink(self._server_db)
os.unlink(self._server_key)
backup_path, = backup_path_list
# user2 sacrifice their private key, and prepare its replacement
basename = self._getBaseName()
user2_newnew_key_path = self._createPrivateKey(basename)
user2_newnew_csr_path = self._createBasicCSR(
basename,
user2_newnew_key_path,
)
user2_new_bare_key_path = user2_new_key_path + '.bare_key'
with open(user2_new_bare_key_path, 'w') as bare_key_file:
bare_key_file.write(utils.getKeyPair(user2_new_key_path)[1])
self.assertEqual(
self._restoreServer(
backup_path,
user2_new_bare_key_path,
user2_newnew_csr_path,
user2_newnew_key_path,
try_count=50,
),
0,
)
# This file is part of caucase
# Copyright (C) 2017 Nexedi
# 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/>.
# See http://peak.telecommunity.com/DevCenter/setuptools#namespace-packages
try:
__import__('pkg_resources').declare_namespace(__name__)
except ImportError:
from pkgutil import extend_path
__path__ = extend_path(__path__, __name__)
# This file is part of caucase
# Copyright (C) 2017 Nexedi
# 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/>.
import os, time
import shutil
import tempfile
import unittest
import json
from datetime import datetime
from caucase import app, db
from OpenSSL import crypto, SSL
from caucase.exceptions import (NoStorage, NotFound, Found, BadSignature,
BadCertificateSigningRequest,
BadCertificate,
CertificateVerificationError,
ExpiredCertificate)
from caucase.ca import CertificateAuthority, DEFAULT_DIGEST_LIST, MIN_CA_RENEW_PERIOD
from caucase import utils
class CertificateAuthorityTest(unittest.TestCase):
def setUp(self):
self.ca_dir = tempfile.mkdtemp()
self.db_file = os.path.join(self.ca_dir, 'ca.db')
self.max_request_amount = 3
self.default_digest = "sha256"
self.crt_keep_time = 0
app.config.update(
DEBUG=True,
CSRF_ENABLED=True,
SECRET_KEY = 'This is an UNSECURE Secret. Please CHANGE THIS for production environments.',
TESTING=True,
SQLALCHEMY_DATABASE_URI='sqlite:///%s' % self.db_file
)
from caucase.storage import Storage
self._storage = Storage(db)
def make_ca(self, crt_life_time, auto_sign_csr=False):
return CertificateAuthority(
storage=self._storage,
ca_life_period=4,
ca_renew_period=2,
crt_life_time=crt_life_time,
crl_renew_period=0.1,
crl_base_url='http://crl.url.com',
ca_subject='/C=XX/ST=State/L=City/OU=OUnit/O=Company/CN=CAAuth/emailAddress=xx@example.com',
max_csr_amount=self.max_request_amount,
crt_keep_time=self.crt_keep_time,
auto_sign_csr=auto_sign_csr
)
def generateCSR(self, cn="toto.example.com", email="toto@example.com"):
key = crypto.PKey()
key.generate_key(crypto.TYPE_RSA, 2048)
req = crypto.X509Req()
subject = req.get_subject()
subject.CN = cn
subject.C = "CC"
subject.ST = "ST"
subject.L = "LOU"
subject.O = "OOU"
subject.OU = "OU"
subject.emailAddress = email
req.set_pubkey(key)
utils.X509Extension().setDefaultCsrExtensions(req)
req.sign(key, self.default_digest)
return (req, key)
def csr_tostring(self, csr):
return crypto.dump_certificate_request(crypto.FILETYPE_PEM, csr)
def get_fake_cert_key(self):
cert_string = """-----BEGIN CERTIFICATE-----
MIID6DCCAtCgAwIBAwIBBDANBgkqhkiG9w0BAQsFADCBljEiMCAGA1UEAwwZVGhl
IENlcnRpZmljYXRlIEF1dGhvcml0eTELMAkGA1UEBhMCWFgxDjAMBgNVBAgMBVN0
YXRlMREwDwYDVQQKDAhDb21wYWdueTENMAsGA1UECwwEVW5pdDENMAsGA1UEBwwE
Q2l0eTEiMCAGCSqGSIb3DQEJARYTY2EuYXV0aEBleGFtcGxlLmNvbTAeFw0xNzA0
MTYyMTQyNDVaFw0xODA0MTYyMTQyNDVaMBsxGTAXBgNVBAMMEGRhbmRAZXhhbXBs
ZS5jb20wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDrJNil62fkNm1j
UgEZ33hg5qYiLeNOEUgKaINCqhfQDuH7tTho+nRNxY2FSpv7ooyMckLYojNm+XEl
lUREE8hgIBiBWgiXazvoaxKFW2BIm7kpfxSC0t4pxwjehftm0Ny/nvms6SiE0ruL
3u+oUI9VVvgofra7mvhX3OZuZNb2QADaPnmyfB8VYGfzYAl0QgyFkrWzPflb/UXD
LdIP/niTpUHMgDTChQ+jf3tHm0pbsRZTxXISJY2+O5qpEgumW+Qcw5sKpjdfMYvH
hoM0IzMofBSfmQCZOjJRcisVLTASj5qN5+GFJi6Pz7oih203Ur8elq+iA0hRisA7
g37LNGXTAgMBAAGjgbowgbcwCQYDVR0TBAIwADAsBglghkgBhvhCAQ0EHxYdT3Bl
blNTTCBHZW5lcmF0ZWQgQ2VydGlmaWNhdGUwHQYDVR0OBBYEFPOBwIfuP8mulzmk
MgC1VGLCkwEDMB8GA1UdIwQYMBaAFGXLZo/7QINvcowfmpUEllOAJ6I5MDwGA1Ud
HwQ1MDMwMaAvoC2GK2h0dHBzOi8vWzIwMDE6NjdjOjEyNTQ6ZTpjNDo6Yzc0OF06
ODAwOS9jcmwwDQYJKoZIhvcNAQELBQADggEBAKarSr7WKXznFjbLfbedrci9mtwo
TYVpOUt/nt6lCiJ2wTGQea/e4KQ3WRwlUUHCX/K+G4QEV8OeDIA4uXnx24fclj35
hCYQCaJfIM96Z+elYIisOX3eFZ9cuo4fkODnry+vQNkYuOn/mFe0sVxoBK+oqSl5
/tN7pTFB2CaSBnRrNHquEc6YFoglCjQW4fXzHdQCdx5B7oOg/yloIst2WagXbyvE
zQvWKm6jjB5/xdT5mpxHB/lanSZMGXFnITh8qXrlTxd/tSa3ic6+k1WC++5brUvE
MtldUnSV++fZh9C4xsXyi26ytr2KkwcJcXQbU2PF4iuV6L0eYO2xOgpDCoQ=
-----END CERTIFICATE-----"""
key_string = """-----BEGIN PRIVATE KEY-----
MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDrJNil62fkNm1j
UgEZ33hg5qYiLeNOEUgKaINCqhfQDuH7tTho+nRNxY2FSpv7ooyMckLYojNm+XEl
lUREE8hgIBiBWgiXazvoaxKFW2BIm7kpfxSC0t4pxwjehftm0Ny/nvms6SiE0ruL
3u+oUI9VVvgofra7mvhX3OZuZNb2QADaPnmyfB8VYGfzYAl0QgyFkrWzPflb/UXD
LdIP/niTpUHMgDTChQ+jf3tHm0pbsRZTxXISJY2+O5qpEgumW+Qcw5sKpjdfMYvH
hoM0IzMofBSfmQCZOjJRcisVLTASj5qN5+GFJi6Pz7oih203Ur8elq+iA0hRisA7
g37LNGXTAgMBAAECggEASMRxSv9LekMhnN/OuWv/e7VE6kTbF9ifO6FWJXYvwlIo
utU87Le88ChXgE0zci6+YeQmLZYcZByDWEcWBh89Hgowqy7qg7lKo8UmySAa7r1K
Er5h4Y5R9AnFA9/gidPOzHns+AZ7ZIc2RLWr4qFzicxNJXL5J5twiPgyUy1fnHpg
Ktk3ccgtIe8IJNYS9hGW40X6DZfbiNqnUlGxS0Nsk7RQhEowcuAob7sBf2k6tSAx
qaaB3PYBwGsfgF6Zkq81/ZmzZPoD0vSLURAlglTWgGljqXOqXVnqUFbcyaZeKwqX
4b5MlQMZ09puOz5CGG0HhnTFmt+N1rU3Vsx74G5hWQKBgQD4wHt9WL6DQPQ2/i0l
o6afSd0+DV5HldHGCRt4aF3//bGU3OugNvHVK2ijXEUIDHcpvEW1vwjbqmkFI3Sn
wANCr8YAmu/51uYYyeUP4V35SKBtBBdhUvFOGE3MThJQusAdEYg65T9STQvmpRnJ
Yv5QRlX/jawEtS2H9pZo+WvZpQKBgQDx/tuiU2isBfOrT5MAVxtu/VEZvafwRcHe
0EmRCyW6+rHSA3o2/3f43t6x63qvvk6NYY9rSz/0TZkZ/Y3QihPNqGwGuuzSvsG+
yDfnv6YmtcnBPv2kXHwEeIsd9DdjqT4D3MIHGHo05cu9Ta7oTHVAo8OSQbEqkGwj
oYpuQTz4FwKBgQCxuo1A9OxByWHz/M1zDCdbviHGWTTYftH/5bfr4t3urmt4ChSM
R1WoUjiUJ7Pm2Uk2158TCSgiEvKwSjHqPUXXGtGk0w7M+l8yrOXt378OAnclDPxL
fECO5MyJQerSJWxoGIO2WN9SRVxQcfwnqIQ+BNMjIS0bu/uJHoU/AZ6uRQKBgQC5
FT9Oa5TG3NZ806OOwxCMVtpMYa2sKu4YSB27/VaiJ1MRWO+EWOedRHf2hC+VcmwJ
3fAfE7KaWy8Znb91G+YBiSr2CslOde8gx2laqk2dlbP1RQQhTUrc8IUWJ86lPq/b
rGAJpULyaj7lTiDUMoYLJjVSC0RBVawfpFGH+gVziQKBgQDI+dgmgFv3ULUUTBtP
iPwYrGFWmzcKwevPaIQqrsjJ0TY2INmQZi6pn4ogelUtcRMFjzpBGHrsxJM0RkSy
3A855c5gfp60XOQB3ab0OS0X5/gzDZHThN7wvspUFnZ9i6LhSEOHMEAxwSklCtPq
m4DpuP4nL0ixQJWZuV+qrx6Tow==
-----END PRIVATE KEY-----"""
return (cert_string, key_string)
def check_cert_equal(self, first, second):
if isinstance(first, crypto.X509):
first_string = crypto.dump_certificate(crypto.FILETYPE_PEM, first)
else:
first_string = first
second_string = crypto.dump_certificate(crypto.FILETYPE_PEM, second)
return first_string == second_string
def check_key_equal(self, first, second):
if isinstance(first, crypto.PKey):
first_string = crypto.dump_privatekey(crypto.FILETYPE_PEM, first)
else:
first_string = first
second_string = crypto.dump_privatekey(crypto.FILETYPE_PEM, second)
return first_string == second_string
def check_csr_equal(self, first, second):
if isinstance(first, crypto.X509Req):
first_string = crypto.dump_certificate_request(crypto.FILETYPE_PEM, first)
else:
first_string = first
second_string = crypto.dump_certificate_request(crypto.FILETYPE_PEM, second)
return first_string == second_string
def tearDown(self):
db.session.remove()
db.drop_all()
if os.path.exists(self.ca_dir):
shutil.rmtree(self.ca_dir)
def test_createCAKeyPair(self):
# initials keypair are generated when instanciating ca
ca = self.make_ca(160)
self.assertEquals(len(ca._ca_key_pairs_list), 1)
def renewCAKetPair(self):
ca = self.make_ca(2)
self.assertEquals(len(ca._ca_key_pairs_list), 1)
first = ca._ca_key_pairs_list
# No renew possible
self.assertFalse(ca.renewCAKeyPair())
time.sleep(5)
# ca Certificate should be renewed
self.assertTrue(ca.renewCAKeyPair())
self.assertEquals(len(ca._ca_key_pairs_list), 2)
self.assertTrue(self.check_cert_equal(ca._ca_key_pairs_list[0]['crt'], first['crt']))
self.assertTrue(self.check_key_equal(ca._ca_key_pairs_list[0]['key'], first['key']))
def test_getPendingCertificateRequest(self):
ca = self.make_ca(190)
csr, key = self.generateCSR()
csr_string = self.csr_tostring(csr)
csr_id = ca.createCertificateSigningRequest(csr_string)
stored = ca.getPendingCertificateRequest(csr_id)
self.assertEquals(csr_string, stored)
def test_deletePendingCertificateRequest(self):
ca = self.make_ca(190)
csr, key = self.generateCSR()
csr_string = self.csr_tostring(csr)
csr_id = ca.createCertificateSigningRequest(csr_string)
stored = ca.getPendingCertificateRequest(csr_id)
self.assertEquals(csr_string, stored)
ca.deletePendingCertificateRequest(csr_id)
with self.assertRaises(NotFound):
ca.getPendingCertificateRequest(csr_id)
def test_createCertificate(self):
ca = self.make_ca(190)
csr, key = self.generateCSR()
csr_id = ca.createCertificateSigningRequest(self.csr_tostring(csr))
# sign certificate with default ca keypair
cert_id = ca.createCertificate(csr_id)
cert = ca.getCertificate(cert_id)
x509 = crypto.load_certificate(crypto.FILETYPE_PEM, cert)
self.assertTrue(utils.validateCertAndKey(x509, key))
subj_dict = {'CN': 'toto.example.com',
'C': 'CC',
'ST': 'ST',
'L': 'LOU',
'O': 'OOU',
'OU': 'OU',
'emailAddress': 'toto@example.com'}
for attr in ['C', 'ST', 'L', 'OU', 'O', 'CN', 'emailAddress']:
self.assertEqual(getattr(x509.get_subject(), attr), subj_dict[attr])
with self.assertRaises(NotFound):
ca.getPendingCertificateRequest(csr_id)
def test_createCertificate_custom_subject(self):
ca = self.make_ca(190)
csr, key = self.generateCSR(cn="test certificate", email="some@test.com")
csr_id = ca.createCertificateSigningRequest(self.csr_tostring(csr))
# sign certificate with default ca keypair
subject_dict = dict(CN="real cn", emailAddress="caucase@email.com")
# sign certificate but change subject
cert_id = ca.createCertificate(csr_id, subject_dict=subject_dict)
cert = ca.getCertificate(cert_id)
x509 = crypto.load_certificate(crypto.FILETYPE_PEM, cert)
self.assertTrue(utils.validateCertAndKey(x509, key))
self.assertEqual(x509.get_subject().CN, subject_dict['CN'])
self.assertEqual(x509.get_subject().emailAddress, subject_dict['emailAddress'])
# Others attributes are empty
for attr in ['C', 'ST', 'L', 'OU', 'O']:
self.assertEqual(getattr(x509.get_subject(), attr), None)
with self.assertRaises(NotFound):
ca.getPendingCertificateRequest(csr_id)
def test_createCertificate_custom_subject2(self):
ca = self.make_ca(190)
csr, key = self.generateCSR(cn="test certificate", email="some@test.com")
csr_id = ca.createCertificateSigningRequest(self.csr_tostring(csr))
subject_dict = {'CN': 'some.site.com',
'C': 'FR',
'O': 'My Organisation',
'L': 'Localisation',
'OU': 'Organisation U',
'ST': 'State',
'emailAddress': 'toto@example.com'}
# sign certificate but change subject
cert_id = ca.createCertificate(csr_id, subject_dict=subject_dict)
cert = ca.getCertificate(cert_id)
x509 = crypto.load_certificate(crypto.FILETYPE_PEM, cert)
# certificate is still valid
self.assertTrue(utils.validateCertAndKey(x509, key))
# check that all attributes are set
for attr in ['C', 'ST', 'L', 'OU', 'O']:
self.assertEqual(getattr(x509.get_subject(), attr), subject_dict[attr])
with self.assertRaises(NotFound):
ca.getPendingCertificateRequest(csr_id)
def test_createCertificate_custom_subject_no_cn(self):
ca = self.make_ca(190)
csr, key = self.generateCSR(cn="test certificate", email="some@test.com")
csr_id = ca.createCertificateSigningRequest(self.csr_tostring(csr))
subject_dict = dict(C="FR", emailAddress="caucase@email.com")
# CN is missing, will raise
with self.assertRaises(AttributeError):
ca.createCertificate(csr_id, subject_dict=subject_dict)
def test_getCAKeypairForCertificate(self):
csr, key = self.generateCSR()
ca = self.make_ca(3)
csr_id = ca.createCertificateSigningRequest(self.csr_tostring(csr))
# sign certificate with default ca keypair
cert_id = ca.createCertificate(csr_id)
cert = ca.getCertificate(cert_id)
x509 = crypto.load_certificate(crypto.FILETYPE_PEM, cert)
self.assertTrue(utils.validateCertAndKey(x509, key))
cert_keypair = ca._ca_key_pairs_list[0]
# 2 crt_life_time cycles + 1s
time.sleep(7)
# Create new CA keypair
self.assertTrue(ca.renewCAKeyPair())
# get keypair which should be used to renew this cert
calculated = ca.getCAKeypairForCertificate(x509)
self.assertTrue(self.check_cert_equal(cert_keypair['crt'], calculated['crt']))
self.assertTrue(self.check_key_equal(cert_keypair['key'], calculated['key']))
self.assertEquals(len(ca._ca_key_pairs_list), 2)
new_keypair = ca._ca_key_pairs_list[-1]
time.sleep(3)
# the first ca keypair cannot be used to renew cert (7+3 -12 = 2 < crt_life_time)
calculated = ca.getCAKeypairForCertificate(x509)
self.assertTrue(self.check_cert_equal(new_keypair['crt'], calculated['crt']))
self.assertTrue(self.check_key_equal(new_keypair['key'], calculated['key']))
def test_renewCertificate(self):
ca = self.make_ca(158)
csr, key = self.generateCSR()
csr_string = self.csr_tostring(csr)
csr_id = ca.createCertificateSigningRequest(csr_string)
# sign certificate with default ca keypair
cert_id = ca.createCertificate(csr_id)
cert = ca.getCertificate(cert_id)
x509 = crypto.load_certificate(crypto.FILETYPE_PEM, cert)
self.assertTrue(utils.validateCertAndKey(x509, key))
payload = dict(
renew_csr=csr_string,
crt=cert
)
wrapped = utils.wrap(payload, crypto.dump_privatekey(crypto.FILETYPE_PEM, key), [self.default_digest])
new_csr_id = ca.renew(wrapped)
new_cert_id = '%s.crt.pem' % new_csr_id[:-8]
new_cert = ca.getCertificate(new_cert_id)
new_x509 = crypto.load_certificate(crypto.FILETYPE_PEM, new_cert)
self.assertTrue(utils.validateCertAndKey(new_x509, key))
self.assertEquals(new_x509.get_subject().CN, x509.get_subject().CN)
# current certificate is not revoked
self.assertTrue(utils.validateCertAndKey(x509, key))
def test_renewCertificate_bad_cert(self):
ca = self.make_ca(158)
csr, key = self.generateCSR()
csr_string = self.csr_tostring(csr)
csr_id = ca.createCertificateSigningRequest(csr_string)
# sign certificate with default ca keypair
cert_id = ca.createCertificate(csr_id)
cert = ca.getCertificate(cert_id)
x509 = crypto.load_certificate(crypto.FILETYPE_PEM, cert)
self.assertTrue(utils.validateCertAndKey(x509, key))
# second certificate
csr2, key2 = self.generateCSR()
csr_id2 = ca.createCertificateSigningRequest(self.csr_tostring(csr2))
cert_id2 = ca.createCertificate(csr_id2)
cert2 = ca.getCertificate(cert_id2)
x509_2 = crypto.load_certificate(crypto.FILETYPE_PEM, cert2)
self.assertTrue(utils.validateCertAndKey(x509_2, key2))
payload = dict(
renew_csr=csr_string,
crt=cert
)
# sign with bad key
wrapped = utils.wrap(payload, crypto.dump_privatekey(crypto.FILETYPE_PEM, key2), [self.default_digest])
with self.assertRaises(BadSignature):
ca.renew(wrapped)
# payload with invalid PEM certificate
payload = dict(
renew_csr=csr_string,
crt="BAD PEM CERTIFICATE"
)
wrapped = utils.wrap(payload, crypto.dump_privatekey(crypto.FILETYPE_PEM, key), [self.default_digest])
with self.assertRaises(BadSignature):
ca.renew(wrapped)
# payload with invalid PEM certificate request content
payload = dict(
renew_csr="BAD PEM CERTIFICATE REQUEST",
crt=cert
)
wrapped = utils.wrap(payload, crypto.dump_privatekey(crypto.FILETYPE_PEM, key), [self.default_digest])
with self.assertRaises(BadCertificateSigningRequest):
ca.renew(wrapped)
# payload with Fake certificate
fcert, fkey = self.get_fake_cert_key()
payload = dict(
renew_csr=csr_string,
crt=cert
)
wrapped = utils.wrap(payload, fkey, [self.default_digest])
with self.assertRaises(BadSignature):
ca.renew(wrapped)
payload = dict(
renew_csr=csr_string,
crt=fcert
)
wrapped = utils.wrap(payload, fkey, [self.default_digest])
with self.assertRaises(BadCertificateSigningRequest):
# Fake certificate and renew_csr has not the same csr
ca.renew(wrapped)
def test_revokeCertificate(self):
ca = self.make_ca(158)
csr, key = self.generateCSR()
csr_id = ca.createCertificateSigningRequest(self.csr_tostring(csr))
# sign certificate with default ca keypair
cert_id = ca.createCertificate(csr_id)
cert = ca.getCertificate(cert_id)
x509 = crypto.load_certificate(crypto.FILETYPE_PEM, cert)
self.assertTrue(utils.validateCertAndKey(x509, key))
payload = dict(
reason='',
revoke_crt=cert
)
wrapped = utils.wrap(payload, crypto.dump_privatekey(crypto.FILETYPE_PEM, key), [self.default_digest])
ca.revokeCertificate(wrapped)
with self.assertRaises(NotFound):
ca.getCertificate(cert_id)
revocation_list = self._storage.getRevocationList()
self.assertEquals(len(revocation_list), 1)
self.assertEquals(revocation_list[0].serial, utils.getSerialToInt(x509))
def test_revokeCertificate_expired(self):
ca = self.make_ca(2)
csr, key = self.generateCSR()
csr_id = ca.createCertificateSigningRequest(self.csr_tostring(csr))
# sign certificate with default ca keypair
cert_id = ca.createCertificate(csr_id)
cert = ca.getCertificate(cert_id)
x509 = crypto.load_certificate(crypto.FILETYPE_PEM, cert)
self.assertTrue(utils.validateCertAndKey(x509, key))
# wait until certificate expire
time.sleep(3)
payload = dict(
reason='',
revoke_crt=cert
)
wrapped = utils.wrap(payload, crypto.dump_privatekey(crypto.FILETYPE_PEM, key), [self.default_digest])
with self.assertRaises(CertificateVerificationError):
# if certificate expire, verification fail
ca.revokeCertificate(wrapped)
def test_revokeCertificate_bad_cert(self):
ca = self.make_ca(158)
csr, key = self.generateCSR()
csr_id = ca.createCertificateSigningRequest(self.csr_tostring(csr))
# sign certificate with default ca keypair
cert_id = ca.createCertificate(csr_id)
cert = ca.getCertificate(cert_id)
x509 = crypto.load_certificate(crypto.FILETYPE_PEM, cert)
self.assertTrue(utils.validateCertAndKey(x509, key))
# second certificate
csr2, key2 = self.generateCSR()
csr_id2 = ca.createCertificateSigningRequest(self.csr_tostring(csr2))
cert_id2 = ca.createCertificate(csr_id2)
cert2 = ca.getCertificate(cert_id2)
x509_2 = crypto.load_certificate(crypto.FILETYPE_PEM, cert2)
self.assertTrue(utils.validateCertAndKey(x509_2, key2))
payload = dict(
reason="",
revoke_crt=cert
)
# sign with bad key
wrapped = utils.wrap(payload, crypto.dump_privatekey(crypto.FILETYPE_PEM, key2), [self.default_digest])
with self.assertRaises(BadSignature):
ca.revokeCertificate(wrapped)
# payload with invalid PEM certificate
payload = dict(
reason="",
revoke_crt="BAD PEM CERTIFICATE"
)
wrapped = utils.wrap(payload, crypto.dump_privatekey(crypto.FILETYPE_PEM, key), [self.default_digest])
with self.assertRaises(BadSignature):
ca.revokeCertificate(wrapped)
# payload with Fake certificate
fcert, fkey = self.get_fake_cert_key()
payload = dict(
reason="",
revoke_crt=cert
)
wrapped = utils.wrap(payload, fkey, [self.default_digest])
with self.assertRaises(BadSignature):
ca.revokeCertificate(wrapped)
payload = dict(
reason="",
revoke_crt=fcert
)
wrapped = utils.wrap(payload, fkey, [self.default_digest])
with self.assertRaises(CertificateVerificationError):
ca.revokeCertificate(wrapped)
def test_getCertificateRevocationList(self):
ca = self.make_ca(158)
def signcert():
csr, key = self.generateCSR()
csr_id = ca.createCertificateSigningRequest(self.csr_tostring(csr))
# sign certificate with default ca keypair
cert_id = ca.createCertificate(csr_id)
cert = ca.getCertificate(cert_id)
x509 = crypto.load_certificate(crypto.FILETYPE_PEM, cert)
self.assertTrue(utils.validateCertAndKey(x509, key))
return x509, key
cert_1, key_1 = signcert()
cert_2, key_2 = signcert()
cert2_string = crypto.dump_certificate(crypto.FILETYPE_PEM, cert_2)
cert_3, key_3 = signcert()
cert3_string = crypto.dump_certificate(crypto.FILETYPE_PEM, cert_3)
cert_4, key_4 = signcert()
crl = ca.getCertificateRevocationList()
crl_obj = crypto.load_crl(crypto.FILETYPE_PEM, crl)
self.assertEquals(crl_obj.get_revoked(), None)
payload = dict(
reason="",
revoke_crt=cert2_string
)
# sign with bad key
wrapped = utils.wrap(payload, crypto.dump_privatekey(crypto.FILETYPE_PEM, key_2), [self.default_digest])
ca.revokeCertificate(wrapped)
crl2_string = ca.getCertificateRevocationList()
crl2 = crypto.load_crl(crypto.FILETYPE_PEM, crl2_string)
self.assertEquals(len(crl2.get_revoked()), 1)
serial = utils.getSerialToInt(cert_2)
self.assertEquals(crl2.get_revoked()[0].get_serial(), serial.upper())
payload = dict(
reason="",
revoke_crt=cert3_string
)
# sign with bad key
wrapped = utils.wrap(payload, crypto.dump_privatekey(crypto.FILETYPE_PEM, key_3), [self.default_digest])
ca.revokeCertificate(wrapped)
crl3_string = ca.getCertificateRevocationList()
crl3 = crypto.load_crl(crypto.FILETYPE_PEM, crl3_string)
self.assertEquals(len(crl3.get_revoked()), 2)
matches = 0
for revoked in crl3.get_revoked():
if revoked.get_serial() == utils.getSerialToInt(cert_3).upper():
matches += 1
elif revoked.get_serial() == utils.getSerialToInt(cert_2).upper():
matches += 1
self.assertEquals(matches, 2)
crl4_string = ca.getCertificateRevocationList()
# nothing changed, crl not expired
self.assertEquals(crl3_string, crl4_string)
def test_getCertificateRevocationList_with_expire(self):
ca = self.make_ca(2)
def signcert():
csr, key = self.generateCSR()
csr_id = ca.createCertificateSigningRequest(self.csr_tostring(csr))
# sign certificate with default ca keypair
cert_id = ca.createCertificate(csr_id)
cert = ca.getCertificate(cert_id)
x509 = crypto.load_certificate(crypto.FILETYPE_PEM, cert)
self.assertTrue(utils.validateCertAndKey(x509, key))
return x509, key
cert_1, key_1 = signcert()
cert_2, key_2 = signcert()
cert2_string = crypto.dump_certificate(crypto.FILETYPE_PEM, cert_2)
crl_string = ca.getCertificateRevocationList()
crl = crypto.load_crl(crypto.FILETYPE_PEM, crl_string)
self.assertEquals(crl.get_revoked(), None)
payload = dict(
reason="",
revoke_crt=cert2_string
)
# sign with bad key
wrapped = utils.wrap(payload, crypto.dump_privatekey(crypto.FILETYPE_PEM, key_2), [self.default_digest])
ca.revokeCertificate(wrapped)
crl2_string = ca.getCertificateRevocationList()
crl2 = crypto.load_crl(crypto.FILETYPE_PEM, crl2_string)
self.assertEquals(len(crl2.get_revoked()), 1)
serial = utils.getSerialToInt(cert_2)
self.assertEquals(crl2.get_revoked()[0].get_serial(), serial.upper())
# wait until cert_2 expire
time.sleep(3)
cert_3, key_3 = signcert()
cert3_string = crypto.dump_certificate(crypto.FILETYPE_PEM, cert_3)
payload = dict(
reason="",
revoke_crt=cert3_string
)
# sign with bad key
wrapped = utils.wrap(payload, crypto.dump_privatekey(crypto.FILETYPE_PEM, key_3), [self.default_digest])
ca.revokeCertificate(wrapped)
crl3_string = ca.getCertificateRevocationList()
crl3 = crypto.load_crl(crypto.FILETYPE_PEM, crl3_string)
# cert_2 is not longer into crl (expired)
self.assertEquals(len(crl3.get_revoked()), 1)
serial = utils.getSerialToInt(cert_3)
self.assertEquals(crl3.get_revoked()[0].get_serial(), serial.upper())
def test_getCertificateRevocationList_with_validation(self):
ca = self.make_ca(158)
def signcert():
csr, key = self.generateCSR()
csr_id = ca.createCertificateSigningRequest(self.csr_tostring(csr))
# sign certificate with default ca keypair
cert_id = ca.createCertificate(csr_id)
cert = ca.getCertificate(cert_id)
x509 = crypto.load_certificate(crypto.FILETYPE_PEM, cert)
self.assertTrue(utils.validateCertAndKey(x509, key))
return x509, key
cert_1, key_1 = signcert()
cert_2, key_2 = signcert()
cert2_string = crypto.dump_certificate(crypto.FILETYPE_PEM, cert_2)
cert_3, key_3 = signcert()
payload = dict(
reason="",
revoke_crt=cert2_string
)
# sign with bad key
wrapped = utils.wrap(payload, crypto.dump_privatekey(crypto.FILETYPE_PEM, key_2), [self.default_digest])
ca.revokeCertificate(wrapped)
crl_string = ca.getCertificateRevocationList()
crl = crypto.load_crl(crypto.FILETYPE_PEM, crl_string)
self.assertEquals(len(crl.get_revoked()), 1)
serial = utils.getSerialToInt(cert_2)
self.assertEquals(crl.get_revoked()[0].get_serial(), serial.upper())
with self.assertRaises(CertificateVerificationError):
utils.verifyCertificateChain(cert_2,
[x['crt'] for x in ca._ca_key_pairs_list], crl)
utils.verifyCertificateChain(cert_3,
[x['crt'] for x in ca._ca_key_pairs_list], crl)
utils.verifyCertificateChain(cert_1,
[x['crt'] for x in ca._ca_key_pairs_list], crl)
# This file is part of caucase
# Copyright (C) 2017 Nexedi
# 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/>.
import os, time
import shutil
import tempfile
import unittest
import json
from datetime import datetime, timedelta
from caucase import app, db
from caucase.storage import Storage, Config
from OpenSSL import crypto, SSL
from caucase.exceptions import (NoStorage, NotFound, Found)
from sqlite3 import IntegrityError
from caucase import utils
import uuid
class StorageTest(unittest.TestCase):
def setUp(self):
self.ca_dir = tempfile.mkdtemp()
self.db_file = os.path.join(self.ca_dir, 'ca.db')
#os.mkdir(self.ca_dir)
self.max_request_amount = 3
self.default_digest = "sha256"
app.config.update(
DEBUG=True,
CSRF_ENABLED=True,
SECRET_KEY = 'This is an UNSECURE Secret. Please CHANGE THIS for production environments.',
TESTING=True,
SQLALCHEMY_DATABASE_URI='sqlite:///%s' % self.db_file
)
self._storage = Storage(db)
def setConfig(self, key, value):
entry = self._storage._getConfig(key)
if not entry:
entry = Config(key=key, value='%s' % value)
db.session.add(entry)
else:
# update value
entry.value = value
db.session.commit()
def tearDown(self):
db.session.remove()
db.drop_all()
if os.path.exists(self.ca_dir):
shutil.rmtree(self.ca_dir)
def createCAKeyPair(self, life_time=60):
key_pair = {}
key = crypto.PKey()
# Use 2048 bits key size
key.generate_key(crypto.TYPE_RSA, 2048)
key_pair['key'] = key
ca = crypto.X509()
ca.set_version(3)
ca.set_serial_number(int(time.time()))
ca.get_subject().CN = "CA Cert %s" % int(time.time())
ca.get_subject().C = "FR"
ca.get_subject().ST = "XX"
ca.get_subject().O = "ORG"
ca.get_subject().OU = "OU"
ca.get_subject().L = "LL"
ca.get_subject().emailAddress = "xxx@exemple.com"
ca.gmtime_adj_notBefore(0)
ca.gmtime_adj_notAfter(life_time)
ca.set_issuer(ca.get_subject())
ca.set_pubkey(key)
utils.X509Extension().setCaExtensions(ca)
ca.sign(key, self.default_digest)
key_pair['crt'] = ca
return key_pair
def generateCSR(self, cn="toto.example.com"):
key = crypto.PKey()
key.generate_key(crypto.TYPE_RSA, 2048)
req = crypto.X509Req()
subject = req.get_subject()
subject.CN = cn
subject.C = "CC"
subject.ST = "ST"
subject.L = "LL"
subject.O = "ORG"
subject.OU = "OU"
subject.emailAddress = "toto@example.com"
req.set_pubkey(key)
utils.X509Extension().setDefaultCsrExtensions(req)
req.sign(key, self.default_digest)
return (req, key)
def createCertificate(self, ca_key_pair, req, expire_sec=180):
serial = uuid.uuid1().int
cert = crypto.X509()
# 3 = v3
cert.set_version(3)
cert.set_serial_number(serial)
cert.gmtime_adj_notBefore(0)
cert.gmtime_adj_notAfter(expire_sec)
cert.set_issuer(ca_key_pair['crt'].get_subject())
cert.set_subject(req.get_subject())
cert.set_pubkey(req.get_pubkey())
utils.X509Extension().setDefaultExtensions(
cert,
subject=cert,
issuer=ca_key_pair['crt'],
crl_url="http://ca.crl.com")
cert.sign(ca_key_pair['key'], self.default_digest)
return cert
def generateCRL(self, ca_key_pair, revocation_list, version_number):
now = datetime.now()
crl = crypto.CRL()
num_crl_days = 1
for revocation in revocation_list:
revoked = crypto.Revoked()
revoked.set_rev_date(
revocation.creation_date.strftime("%Y%m%d%H%M%SZ").encode("ascii")
)
revoked.set_serial(revocation.serial.encode("ascii"))
revoked.set_reason(None) #b'%s' % revocation.reason)
crl.add_revoked(revoked)
crl.set_version(version_number)
# XXX - set how to get the cacert here
cert = ca_key_pair['crt']
key = ca_key_pair['key']
return crypto.load_crl(
crypto.FILETYPE_PEM,
crl.export(
cert,
key,
type=crypto.FILETYPE_PEM,
days=num_crl_days,
digest=self.default_digest)
)
def check_cert_equal(self, first, second):
if isinstance(first, crypto.X509):
first_string = crypto.dump_certificate(crypto.FILETYPE_PEM, first)
else:
first_string = first
second_string = crypto.dump_certificate(crypto.FILETYPE_PEM, second)
return first_string == second_string
def check_key_equal(self, first, second):
if isinstance(first, crypto.PKey):
first_string = crypto.dump_privatekey(crypto.FILETYPE_PEM, first)
else:
first_string = first
second_string = crypto.dump_privatekey(crypto.FILETYPE_PEM, second)
return first_string == second_string
def check_csr_equal(self, first, second):
if isinstance(first, crypto.X509Req):
first_string = crypto.dump_certificate_request(crypto.FILETYPE_PEM, first)
else:
first_string = first
second_string = crypto.dump_certificate_request(crypto.FILETYPE_PEM, second)
return first_string == second_string
def test_db_exists(self):
self.assertTrue(os.path.exists(self.db_file))
def test_config(self):
# no config exists
self.assertEquals(self._storage.getConfig('config-sample'), None)
# with default value
self.assertEquals(self._storage.getConfig('config-sample', "tototo"), "tototo")
# set config (stored as string)
self.setConfig('config-test', 1458)
self.setConfig('test-dict', dict(first=23, second="sec"))
# values are string
self.assertEquals(self._storage.getConfig('config-test'), "1458")
self.assertEquals(self._storage.getConfig('test-dict'), str(dict(first=23, second="sec")))
def test_store_CAKeypair(self):
keypair = self.createCAKeyPair()
self._storage.storeCAKeyPair(keypair)
# check stored keypair
stored = self._storage.getCAKeyPairList()
self.assertEquals(len(stored), 1)
self.assertTrue(self.check_cert_equal(stored[0]['crt'], keypair['crt']))
self.assertTrue(self.check_key_equal(stored[0]['key'], keypair['key']))
def test_store_same_keypair(self):
keypair = self.createCAKeyPair()
self._storage.storeCAKeyPair(keypair)
# check stored keypair
stored = self._storage.getCAKeyPairList()
self.assertEquals(len(stored), 1)
self.assertTrue(self.check_cert_equal(stored[0]['crt'], keypair['crt']))
self.assertTrue(self.check_key_equal(stored[0]['key'], keypair['key']))
# store again the same keypair
with self.assertRaises(Found):
self._storage.storeCAKeyPair(keypair)
def test_store_keypair_multiple(self):
keypair = self.createCAKeyPair()
self._storage.storeCAKeyPair(keypair)
# check stored keypair
stored = self._storage.getCAKeyPairList()
self.assertEquals(len(stored), 1)
self.assertTrue(self.check_cert_equal(stored[0]['crt'], keypair['crt']))
self.assertTrue(self.check_key_equal(stored[0]['key'], keypair['key']))
time.sleep(1)
keypair2 = self.createCAKeyPair()
self._storage.storeCAKeyPair(keypair2)
stored2 = self._storage.getCAKeyPairList()
self.assertEquals(len(stored2), 2)
# check that order of keypair is good
first = stored2[0]
self.assertTrue(self.check_cert_equal(first['crt'], keypair['crt']))
self.assertTrue(self.check_key_equal(first['key'], keypair['key']))
second = stored2[1]
self.assertTrue(self.check_cert_equal(second['crt'], keypair2['crt']))
self.assertTrue(self.check_key_equal(second['key'], keypair2['key']))
def test_storeCertificateSigningRequest(self):
csr, key = self.generateCSR()
csr_id = self._storage.storeCertificateSigningRequest(csr)
stored = self._storage.getPendingCertificateRequest(csr_id)
self.assertTrue(self.check_csr_equal(stored, csr))
def test_deletePendingCertificateRequest(self):
csr, key = self.generateCSR()
csr_id = self._storage.storeCertificateSigningRequest(csr)
stored = self._storage.getPendingCertificateRequest(csr_id)
self.assertTrue(self.check_csr_equal(stored, csr))
self._storage.deletePendingCertificateRequest(csr_id)
with self.assertRaises(NotFound):
self._storage.getPendingCertificateRequest(csr_id)
def test_storeCertificateSigningRequest_no_storage(self):
self.setConfig('max-csr-amount', self.max_request_amount)
self.assertEquals(int(self._storage.getConfig('max-csr-amount')), self.max_request_amount)
for i in range(0, self.max_request_amount):
csr, _ = self.generateCSR()
self._storage.storeCertificateSigningRequest(csr)
# will raise NoStorage now
csr, _ = self.generateCSR()
with self.assertRaises(NoStorage):
self._storage.storeCertificateSigningRequest(csr)
csr_list = self._storage.getPendingCertificateRequestList()
self.assertEquals(len(csr_list), self.max_request_amount)
def store_storeCertificateSigningRequest_same(self):
csr, key = self.generateCSR()
csr_id = self._storage.storeCertificateSigningRequest(csr)
stored = self._storage.getPendingCertificateRequest(csr_id)
self.assertTrue(self.check_csr_equal(stored, csr))
# store a second time the same csr
csr2_id = self._storage.storeCertificateSigningRequest(csr)
self.assertEquals(csr2_id, csr_id)
stored2 = self._storage.getPendingCertificateRequest(csr2_id)
self.assertEquals(stored2, stored)
csr_list = self._storage.getPendingCertificateRequestList()
# there is only on csr in the list
self.assertEquals(len(csr_list), 1)
def test_storeCertificate(self):
keypair = self.createCAKeyPair()
self._storage.storeCAKeyPair(keypair)
csr, _ = self.generateCSR()
csr_id = self._storage.storeCertificateSigningRequest(csr)
cert = self.createCertificate(keypair, csr)
cert_id = self._storage.storeCertificate(csr_id, cert)
stored = self._storage.getCertificate(cert_id)
self.assertTrue(self.check_cert_equal(stored, cert))
def test_storeCertificate_wrong_csr(self):
keypair = self.createCAKeyPair()
self._storage.storeCAKeyPair(keypair)
csr, _ = self.generateCSR()
cert = self.createCertificate(keypair, csr)
csr_id="1234"
# csr_id not exists
with self.assertRaises(NotFound):
self._storage.storeCertificate(csr_id, cert)
csr_id = self._storage.storeCertificateSigningRequest(csr)
self._storage.deletePendingCertificateRequest(csr_id)
# csr was deleted
with self.assertRaises(NotFound):
self._storage.storeCertificate(csr_id, cert)
def test_storeCertificate_same(self):
keypair = self.createCAKeyPair()
self._storage.storeCAKeyPair(keypair)
csr, _ = self.generateCSR()
csr_id = self._storage.storeCertificateSigningRequest(csr)
cert = self.createCertificate(keypair, csr)
cert_id = self._storage.storeCertificate(csr_id, cert)
stored = self._storage.getCertificate(cert_id)
self.assertTrue(self.check_cert_equal(stored, cert))
with self.assertRaises(NotFound):
# CSR not found
self._storage.storeCertificate(csr_id, cert)
def test_storeCertificate_same_csr(self):
keypair = self.createCAKeyPair()
self._storage.storeCAKeyPair(keypair)
csr, _ = self.generateCSR()
csr_id = self._storage.storeCertificateSigningRequest(csr)
cert = self.createCertificate(keypair, csr)
cert_id = self._storage.storeCertificate(csr_id, cert)
stored = self._storage.getCertificate(cert_id)
self.assertTrue(self.check_cert_equal(stored, cert))
with self.assertRaises(NotFound):
# CSR not found
self._storage.storeCertificate(csr_id, cert)
csr2_id = self._storage.storeCertificateSigningRequest(csr)
self.assertNotEquals(csr2_id, csr_id)
#with self.assertRaises(IntegrityError):
# cannot store 2 certificate with same serial
# self._storage.storeCertificate(csr2_id, cert)
# can store new certificate, using a different csr2_id from csr
cert2 = self.createCertificate(keypair, csr)
cert2_id = self._storage.storeCertificate(csr2_id, cert2)
self.assertNotEquals(cert2_id, cert_id)
def test_revokeCertificate(self):
keypair = self.createCAKeyPair()
self._storage.storeCAKeyPair(keypair)
csr, _ = self.generateCSR()
csr_id = self._storage.storeCertificateSigningRequest(csr)
cert = self.createCertificate(keypair, csr)
cert_id = self._storage.storeCertificate(csr_id, cert)
expiration_date = datetime.strptime(cert.get_notAfter(), '%Y%m%d%H%M%SZ')
self._storage.revokeCertificate(utils.getSerialToInt(cert), expiration_date)
with self.assertRaises(NotFound):
# certificate was revoked
self._storage.getCertificate(cert_id)
revocation_list = self._storage.getRevocationList()
self.assertEquals(len(revocation_list), 1)
self.assertEquals(revocation_list[0].serial, utils.getSerialToInt(cert))
self.assertEquals(revocation_list[0].crt_expire_after, expiration_date)
def test_revokeCertificate_not_exists(self):
keypair = self.createCAKeyPair()
self._storage.storeCAKeyPair(keypair)
csr, _ = self.generateCSR()
csr_id = self._storage.storeCertificateSigningRequest(csr)
cert = self.createCertificate(keypair, csr)
cert_id = self._storage.storeCertificate(csr_id, cert)
expiration_date = datetime.strptime(cert.get_notAfter(), '%Y%m%d%H%M%SZ')
revocation_list = self._storage.getRevocationList()
self.assertEquals(revocation_list, [])
self._storage.revokeCertificate(utils.getSerialToInt(cert), expiration_date)
with self.assertRaises(NotFound):
# certificate was revoked
self._storage.revokeCertificate(utils.getSerialToInt(cert), expiration_date)
def test_storeCertificateRevocation_with_expired(self):
keypair = self.createCAKeyPair()
self._storage.storeCAKeyPair(keypair)
def store_cert(expire_sec):
csr, _ = self.generateCSR()
csr_id = self._storage.storeCertificateSigningRequest(csr)
cert = self.createCertificate(keypair, csr, expire_sec=expire_sec)
cert_id = self._storage.storeCertificate(csr_id, cert)
return cert_id, cert
id1, cert1 = store_cert(5)
id2, cert2 = store_cert(260)
id3, cert3 = store_cert(180)
expiration_date1 = datetime.strptime(cert1.get_notAfter(), '%Y%m%d%H%M%SZ')
expiration_date3 = datetime.strptime(cert3.get_notAfter(), '%Y%m%d%H%M%SZ')
self._storage.revokeCertificate(utils.getSerialToInt(cert1), expiration_date1)
self._storage.revokeCertificate(utils.getSerialToInt(cert3), expiration_date3)
revocation_list = self._storage.getRevocationList()
self.assertEquals(len(revocation_list), 2)
stored_cert = self._storage.getCertificate(id2)
self.assertTrue(self.check_cert_equal(stored_cert, cert2))
for i in [0, 1]:
if revocation_list[0].serial == utils.getSerialToInt(cert1):
self.assertEquals(revocation_list[0].crt_expire_after, expiration_date1)
revocation_list.pop(0)
elif revocation_list[0].serial == utils.getSerialToInt(cert3):
self.assertEquals(revocation_list[0].crt_expire_after, expiration_date3)
revocation_list.pop(0)
# All items was removed with pop(0)
self.assertEquals(revocation_list, [])
time.sleep(5)
revocation_list = self._storage.getRevocationList()
# revoked cert1 certificate has expired notAfter
self.assertEquals(len(revocation_list), 1)
self.assertEquals(revocation_list[0].serial, utils.getSerialToInt(cert3))
with self.assertRaises(NotFound):
self._storage.getCertificate(id1)
def test_getNextCRLVersionNumber(self):
serial = self._storage.getNextCRLVersionNumber()
self.assertEquals(serial, 1)
def test_storeCertificateRevocationList(self):
keypair = self.createCAKeyPair()
self._storage.storeCAKeyPair(keypair)
def store_cert(expire_sec):
csr, _ = self.generateCSR()
csr_id = self._storage.storeCertificateSigningRequest(csr)
cert = self.createCertificate(keypair, csr, expire_sec=expire_sec)
cert_id = self._storage.storeCertificate(csr_id, cert)
return cert_id, cert
id1, cert1 = store_cert(60)
id2, cert2 = store_cert(260)
id3, cert3 = store_cert(180)
expiration_date1 = datetime.strptime(cert1.get_notAfter(), '%Y%m%d%H%M%SZ')
expiration_date3 = datetime.strptime(cert3.get_notAfter(), '%Y%m%d%H%M%SZ')
self._storage.revokeCertificate(utils.getSerialToInt(cert1), expiration_date1)
self._storage.revokeCertificate(utils.getSerialToInt(cert3), expiration_date3)
revocation_list = self._storage.getRevocationList()
self.assertEquals(len(revocation_list), 2)
crl = self.generateCRL(keypair, revocation_list, 1)
self._storage.storeCertificateRevocationList(
crl,
expiration_date=(datetime.utcnow() + timedelta(0, 160))
)
stored = self._storage.getCertificateRevocationList()
self.assertNotEqual(stored, None)
crl_string = crypto.dump_crl(crypto.FILETYPE_PEM, crl)
self.assertEquals(crl_string, stored)
def test_getCertificateRevocationList_empty(self):
keypair = self.createCAKeyPair()
crl = self._storage.getCertificateRevocationList()
self.assertEqual(crl, None)
crl = self.generateCRL(keypair, [], 1)
self._storage.storeCertificateRevocationList(
crl,
expiration_date=(datetime.utcnow() + timedelta(0, 3))
)
stored = self._storage.getCertificateRevocationList()
self.assertNotEqual(stored, None)
crl_string = crypto.dump_crl(crypto.FILETYPE_PEM, crl)
self.assertEquals(crl_string, stored)
time.sleep(4)
stored = self._storage.getCertificateRevocationList()
# crl is not valid anymore
self.assertEqual(stored, None)
def test_housekeep(self):
from caucase.storage import CertificateRequest, Certificate, CAKeypair, CertificateRevocationList, Revocation
self.setConfig('crt-keep-time', 5)
self.setConfig('csr-keep-time', 5)
# ca cert expire after 4 seconds
keypair = self.createCAKeyPair(5)
self._storage.storeCAKeyPair(keypair)
time.sleep(1)
keypair2 = self.createCAKeyPair(15)
self._storage.storeCAKeyPair(keypair2)
self.assertEquals(len(self._storage.getCAKeyPairList()), 2)
def store_cert(expire_sec):
csr, _ = self.generateCSR()
csr_id = self._storage.storeCertificateSigningRequest(csr)
cert = self.createCertificate(keypair, csr, expire_sec=expire_sec)
cert_id = self._storage.storeCertificate(csr_id, cert)
return cert_id, cert
id1, cert1 = store_cert(4)
id2, cert2 = store_cert(4)
id3, cert3 = store_cert(8)
expiration_date1 = datetime.strptime(cert2.get_notAfter(), '%Y%m%d%H%M%SZ')
self._storage.revokeCertificate(utils.getSerialToInt(cert2), expiration_date1)
revocation_list = self._storage.getRevocationList()
self.assertEquals(len(revocation_list), 1)
crl = self.generateCRL(keypair, revocation_list, 2)
self._storage.storeCertificateRevocationList(
crl,
expiration_date=(datetime.utcnow() + timedelta(0, 4))
)
time.sleep(2)
id_4, cert4 = store_cert(10)
id_5, cert5 = store_cert(10)
expiration_date3 = datetime.strptime(cert4.get_notAfter(), '%Y%m%d%H%M%SZ')
self._storage.revokeCertificate(utils.getSerialToInt(cert4), expiration_date3)
revocation_list = self._storage.getRevocationList()
self.assertEquals(len(revocation_list), 2)
crl2 = self.generateCRL(keypair, revocation_list, 3)
self._storage.storeCertificateRevocationList(
crl,
expiration_date=(datetime.utcnow() + timedelta(0, 10))
)
time.sleep(3)
crl_list = CertificateRevocationList.query.filter().all()
self.assertEquals(len(crl_list), 2)
revocation_list = Revocation.query.filter().all()
self.assertEquals(len(revocation_list), 2)
self._storage.housekeep()
# One key_pair is expired
stored = self._storage.getCAKeyPairList()
self.assertEquals(len(stored), 1)
self.assertTrue(self.check_cert_equal(stored[0]['crt'], keypair2['crt']))
self.assertTrue(self.check_key_equal(stored[0]['key'], keypair2['key']))
csr_count = CertificateRequest.query.filter().count()
# 3 csr should be removed
self.assertEquals(csr_count, 2)
with self.assertRaises(NotFound):
self._storage.getCertificate(id1)
self._storage.getCertificate(id2)
self._storage.getCertificate(id3)
with self.assertRaises(NotFound):
# this certificate is revoked
self._storage.getCertificate(id_4)
# content still exists (keep-time not expired)
self._storage.getCertificate(id_5)
# certificate informations are not remove
cert_count = Certificate.query.filter().count()
self.assertEquals(cert_count, 5)
revocation_list = Revocation.query.filter().all()
self.assertEquals(len(revocation_list), 1)
self.assertTrue(revocation_list[0].serial == utils.getSerialToInt(cert4))
# one crl is removed
crl_list = CertificateRevocationList.query.filter().all()
self.assertEquals(len(crl_list), 1)
# This file is part of caucase
# Copyright (C) 2017 Nexedi
# 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/>.
import os, time
import urllib2
import shutil
import tempfile
import json
from datetime import datetime
from caucase.web import parseArguments, configure_flask
from OpenSSL import crypto, SSL
from caucase.exceptions import (NoStorage, NotFound, Found)
from caucase import utils
from flask_testing import TestCase
from flask import url_for
class CertificateAuthorityWebTest(TestCase):
def setUp(self):
self.ca_dir = tempfile.mkdtemp()
configure_flask(parseArguments(['--ca-dir', self.ca_dir, '-s', '/CN=CA Auth Test/emailAddress=xx@example.com']))
def tearDown(self):
self.db.session.remove()
self.db.drop_all()
if os.path.exists(self.ca_dir):
shutil.rmtree(self.ca_dir)
def create_app(self):
from caucase import db, app
app.config['TESTING'] = True
app.config['LIVESERVER_PORT'] = 0
self.db = db
return app
def generateCSR(self, cn="toto.example.com"):
key = crypto.PKey()
key.generate_key(crypto.TYPE_RSA, 2048)
req = crypto.X509Req()
subject = req.get_subject()
subject.CN = cn
subject.C = "CC"
subject.ST = "ST"
subject.L = "LO"
subject.O = "ORG"
subject.OU = "OU"
subject.emailAddress = "toto@example.com"
req.set_pubkey(key)
utils.X509Extension().setDefaultCsrExtensions(req)
req.sign(key, 'sha256')
return (req, key)
def _init_password(self, password):
response = self.client.post('/admin/setpassword', data=dict(password=password),
follow_redirects=True)
self.assert200(response)
def test_get_crl(self):
response = self.client.get('/crl')
self.assert200(response)
crypto.load_crl(crypto.FILETYPE_PEM, response.data)
def test_put_csr(self):
csr, _ = self.generateCSR()
csr_string = crypto.dump_certificate_request(crypto.FILETYPE_PEM, csr)
data = dict(csr=csr_string)
response = self.client.put(url_for('request_cert'), data=data)
self.assertEquals(response.status_code, 201)
csr_key = response.headers['Location'].split('/')[-1]
# the first csr is signed and csr is no longer available
response2 = self.client.get('/csr/%s' % csr_key)
self.assert404(response2)
cert_url = '/crt/%s.crt.pem' % csr_key[:-8]
response3 = self.client.get(cert_url)
self.assert200(response3)
crypto.load_certificate(crypto.FILETYPE_PEM, response3.data)
# put another csr
csr2, _ = self.generateCSR()
csr2_string = crypto.dump_certificate_request(crypto.FILETYPE_PEM, csr)
response4 = self.client.put(url_for('request_cert'), data=dict(csr=csr2_string))
self.assertEquals(response4.status_code, 201)
csr_key = response4.headers['Location'].split('/')[-1]
# get csr will return the csr
response5 = self.client.get('/csr/%s' % csr_key)
self.assert200(response5)
# the certificate is not signed
cert_url = '/crt/%s.crt.pem' % csr_key[:-8]
response6 = self.client.get(cert_url)
self.assert404(response6)
self.assertEquals(response5.data, csr2_string)
def test_get_csr_notfound(self):
response = self.client.get('/csr/1234')
self.assert404(response)
# self.assertEquals(response.json['name'], 'FileNotFound'))
def test_get_cacert(self):
response = self.client.get('/crt/ca.crt.pem')
self.assert200(response)
crypto.load_certificate(crypto.FILETYPE_PEM, response.data)
...@@ -15,240 +15,396 @@ ...@@ -15,240 +15,396 @@
# #
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with caucase. If not, see <http://www.gnu.org/licenses/>. # along with caucase. If not, see <http://www.gnu.org/licenses/>.
"""
Caucase - Certificate Authority for Users, Certificate Authority for SErvices
Small-ish functions needed in many places.
"""
from __future__ import absolute_import
from collections import defaultdict
import datetime
import json import json
import os
import time
from cryptography import x509
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization, hashes
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives.asymmetric import padding
import cryptography.exceptions
import pem
from .exceptions import CertificateVerificationError
DEFAULT_DIGEST_LIST = ('sha256', 'sha384', 'sha512')
DEFAULT_DIGEST = DEFAULT_DIGEST_LIST[0]
DEFAULT_DIGEST_CLASS = getattr(hashes, DEFAULT_DIGEST.upper())
# load-time sanity check
def _checkDefaultDigestsAvailable():
for x in DEFAULT_DIGEST_LIST:
getattr(hashes, x.upper())
_checkDefaultDigestsAvailable()
del _checkDefaultDigestsAvailable
_cryptography_backend = default_backend()
# Registration-less OID under 2.25 tree (aka uuid tree)
CAUCASE_OID_TOP = '2.25.285541874270823339875695650038637483517'
CAUCASE_OID_AUTO_SIGNED = CAUCASE_OID_TOP + '.0'
# Reserved for tests: no meaning, always stripped but never specificaly
# checked for in the code.
CAUCASE_OID_RESERVED = CAUCASE_OID_TOP + '.999'
_CAUCASE_OID_AUTO_SIGNED = x509.oid.ObjectIdentifier(CAUCASE_OID_AUTO_SIGNED)
CAUCASE_POLICY_INFORMATION_AUTO_SIGNED = x509.PolicyInformation(
_CAUCASE_OID_AUTO_SIGNED,
[
x509.UserNotice(
None,
'Auto-signed caucase certificate',
),
]
)
def isCertificateAutoSigned(crt):
"""
Checks whether given certificate was automatically signed by caucase.
from caucase.exceptions import BadSignature, CertificateVerificationError Allows ensuring no rogue certificate could be emitted before legitimate owner
from OpenSSL import crypto, SSL could take control of their instance: in such case, "first" certificate would
from pyasn1.codec.der import encoder as der_encoder not appear as auto-signed.
from pyasn1.type import tag
from pyasn1_modules import rfc2459
class GeneralNames(rfc2459.GeneralNames): Returns True if certificate is auto-signed, False otherwise.
"""
rfc2459 has wrong tagset.
""" """
tagSet = tag.TagSet( try:
(), extension = crt.extensions.get_extension_for_class(
tag.Tag(tag.tagClassContext, tag.tagFormatConstructed, 0), x509.CertificatePolicies,
) )
except x509.ExtensionNotFound:
pass
else:
for policy_information in extension.value:
if policy_information.policy_identifier == _CAUCASE_OID_AUTO_SIGNED:
return True
return False
class DistributionPointName(rfc2459.DistributionPointName): def _getPEMTypeDict(path, result=None):
""" if result is None:
rfc2459 has wrong tagset. result = defaultdict(list)
""" for entry in pem.parse_file(path):
tagSet = tag.TagSet( result[pem.Key if isinstance(entry, pem.Key) else type(entry)].append(
(), entry,
tag.Tag(tag.tagClassContext, tag.tagFormatConstructed, 0),
) )
return result
class ExtensionType(): def getCertList(crt_path):
"""
Return a list of certificates.
Raises if there is anything else than a certificate.
"""
type_dict = _getPEMTypeDict(crt_path)
crt_list = type_dict.pop(pem.Certificate)
if type_dict:
raise ValueError('%s contains more than just certificates' % (crt_path, ))
return [x.as_bytes() for x in crt_list]
CRL_DIST_POINTS = "crlDistributionPoints" def getCert(crt_path):
BASIC_CONSTRAINTS = "basicConstraints" """
KEY_USAGE = "keyUsage" Return a certificate from a file which may also contain a key.
NS_CERT_TYPE = "nsCertType" Raises if there is more or less than one certificate.
NS_COMMENT = "nsComment" """
SUBJECT_KEY_ID = "subjectKeyIdentifier" type_dict = _getPEMTypeDict(crt_path)
AUTH_KEY_ID = "authorityKeyIdentifier" crt, = type_dict.get(pem.Certificate)
return crt.as_bytes()
def hasOneCert(crt_path):
"""
Returns whether crt_path contains a certificate.
class X509Extension(object): False if there is no file at crt_path.
Raises if there is more than one certificate.
Ignores other types.
"""
if os.path.exists(crt_path):
crt_list = _getPEMTypeDict(crt_path).get(pem.Certificate, [])
if crt_list:
_, = crt_list
return True
return False
known_extension_list = [name for (attr, name) in vars(ExtensionType).items() def getCertRequest(csr_path):
if attr.isupper()] """
Return a certificate request from a file.
Raises if there is more or less than one certificate request, or anything
else.
"""
type_dict = _getPEMTypeDict(csr_path)
csr, = type_dict.pop(pem.CertificateRequest)
if type_dict:
raise ValueError('%s contains more than just a csr' % (csr_path, ))
return csr.as_bytes()
def setX509Extension(self, ext_type, critical, value, subject=None, issuer=None): def getKey(key_path):
if not ext_type in self.known_extension_list: """
raise ValueError('Extension type is not known from ExtensionType class') Return a key from a file.
Raises if there is more or less than one key, or anything else.
"""
type_dict = _getPEMTypeDict(key_path)
key, = type_dict.pop(pem.Key)
if type_dict:
raise ValueError('%s contains more than just a key' % (key_path, ))
return key.as_bytes()
if ext_type == ExtensionType.CRL_DIST_POINTS: def getKeyPair(crt_path, key_path=None):
cdp = self._getCrlDistPointExt(value) """
return crypto.X509Extension( Return a certificate and a key from a pair of file.
b'%s' % ext_type, If crt_path contains both a cert and a key, key_path is ignored.
critical, Raises if there is more than one certificate or more than one key.
'DER:' + cdp.encode('hex'), Raises if key and cert do not match.
subject=subject, """
issuer=issuer, type_dict = _getPEMTypeDict(crt_path)
) if pem.Key not in type_dict and key_path:
_getPEMTypeDict(key_path, type_dict)
else: else:
return crypto.X509Extension( key_path = None
ext_type, key, = type_dict[pem.Key]
critical, crt, = type_dict[pem.Certificate]
value, key = key.as_bytes()
subject=subject, crt = crt.as_bytes()
issuer=issuer, validateCertAndKey(crt, key)
) return crt, key, key_path
def _getCrlDistPointExt(self, cdp_list):
cdp = rfc2459.CRLDistPointsSyntax()
position = 0
for cdp_type, cdp_value in cdp_list:
cdp_entry = rfc2459.DistributionPoint()
general_name = rfc2459.GeneralName()
if not cdp_type in ['dNSName', 'directoryName', 'uniformResourceIdentifier']:
raise ValueError("crlDistributionPoints GeneralName '%s' is not valid" % cdp_type)
general_name.setComponentByName(cdp_type, cdp_value)
general_names = GeneralNames()
general_names.setComponentByPosition(0, general_name)
name = DistributionPointName()
name.setComponentByName('fullName', general_names)
cdp_entry.setComponentByName('distributionPoint', name)
cdp.setComponentByPosition(position, cdp_entry)
position += 1
return der_encoder.encode(cdp)
def setCaExtensions(self, cert_obj):
"""
extensions for default certificate
"""
cert_obj.add_extensions([
self.setX509Extension(ExtensionType.BASIC_CONSTRAINTS, True,
"CA:TRUE, pathlen:0"),
self.setX509Extension(ExtensionType.NS_COMMENT,
False, "OpenSSL CA Certificate"),
self.setX509Extension(ExtensionType.KEY_USAGE,
True, "keyCertSign, cRLSign"),
self.setX509Extension(ExtensionType.SUBJECT_KEY_ID,
False, "hash", subject=cert_obj),
])
cert_obj.add_extensions([
self.setX509Extension(ExtensionType.AUTH_KEY_ID,
False, "keyid:always,issuer", issuer=cert_obj)
])
def setDefaultExtensions(self, cert_obj, subject=None, issuer=None, crl_url=None):
"""
extensions for default certificate
"""
cert_obj.add_extensions([
self.setX509Extension(ExtensionType.BASIC_CONSTRAINTS, False, "CA:FALSE"),
self.setX509Extension(ExtensionType.NS_COMMENT,
False, "OpenSSL Generated Certificate"),
self.setX509Extension(ExtensionType.SUBJECT_KEY_ID,
False, "hash", subject=subject),
])
cert_obj.add_extensions([
self.setX509Extension(ExtensionType.AUTH_KEY_ID,
False, "keyid,issuer", issuer=issuer)
])
if crl_url:
cert_obj.add_extensions([
self.setX509Extension(ExtensionType.CRL_DIST_POINTS,
False, [("uniformResourceIdentifier", crl_url)])
])
def setDefaultCsrExtensions(self, cert_obj, subject=None, issuer=None):
"""
extensions for certificate signature request
"""
cert_obj.add_extensions([
self.setX509Extension(ExtensionType.BASIC_CONSTRAINTS, False, "CA:FALSE"),
self.setX509Extension(ExtensionType.KEY_USAGE,
False, "nonRepudiation, digitalSignature, keyEncipherment"),
])
def getSerialToInt(x509):
return '{0:x}'.format(int(x509.get_serial_number()))
def validateCertAndKey(cert_pem, key_pem): def validateCertAndKey(cert_pem, key_pem):
ctx = SSL.Context(SSL.TLSv1_METHOD) """
ctx.use_privatekey(key_pem) Verify certificate and key match.
ctx.use_certificate(cert_pem)
try:
ctx.check_privatekey()
except SSL.Error:
return False
else:
return True
def verifyCertificateChain(cert_pem, trusted_cert_list, crl=None): Raises if it is not the case.
"""
if x509.load_pem_x509_certificate(
cert_pem,
_cryptography_backend,
).public_key().public_numbers() != load_privatekey(
key_pem,
).public_key().public_numbers():
raise ValueError('Mismatch between private key and certificate')
def _verifyCertificateChain(cert, trusted_cert_list, crl):
"""
Verifies whether certificate has been signed by any of the trusted
certificates, is not revoked and is whithin its validity period.
# Create and fill a X509Sore with trusted certs Raises CertificateVerificationError if validation fails.
"""
# Note: this function (validating a certificate without an SSL connection)
# does not seem to have many equivalents at all in python. OpenSSL module
# seems to be a rare implementation of it, so we keep using this module.
# BUT it MUST NOT be used anywhere outside this function (hence the
# bad-style local import). Use "cryptography".
from OpenSSL import crypto
store = crypto.X509Store() store = crypto.X509Store()
assert trusted_cert_list
for trusted_cert in trusted_cert_list: for trusted_cert in trusted_cert_list:
store.add_cert(trusted_cert) store.add_cert(crypto.X509.from_cryptography(trusted_cert))
if crl is not None:
if crl: store.add_crl(crypto.CRL.from_cryptography(crl))
store.add_crl(crl)
store.set_flags(crypto.X509StoreFlags.CRL_CHECK) store.set_flags(crypto.X509StoreFlags.CRL_CHECK)
store_ctx = crypto.X509StoreContext(store, cert_pem)
# Returns None if certificate can be validated
try: try:
result = store_ctx.verify_certificate() crypto.X509StoreContext(
except crypto.X509StoreContextError, e: store,
raise CertificateVerificationError('Certificate verification error: %s' % str(e)) crypto.X509.from_cryptography(cert),
except crypto.Error, e: ).verify_certificate()
except (
crypto.X509StoreContextError,
crypto.Error,
), e:
raise CertificateVerificationError('Certificate verification error: %s' % str(e)) raise CertificateVerificationError('Certificate verification error: %s' % str(e))
if result is None: def wrap(payload, key, digest):
return True """
else: Sign payload (which gets json-serialised) with key, using given digest.
return False """
payload = json.dumps(payload).encode('utf-8')
hash_class = getattr(hashes, digest.upper())
return {
'payload': payload,
'digest': digest,
'signature': key.sign(
payload + digest + ' ',
padding.PSS(
mgf=padding.MGF1(hash_class()),
salt_length=padding.PSS.MAX_LENGTH,
),
hash_class(),
).encode('base64'),
}
def checkCertificateValidity(ca_cert_list, cert_pem, key_pem=None): def nullWrap(payload):
"""
Wrap without signature. To only be used (and accepted) when user is
authenticated (and hence using a secure channel, HTTPS).
"""
return {
'payload': json.dumps(payload),
'digest': None,
}
if not verifyCertificateChain(cert_pem, ca_cert_list): def unwrap(wrapped, getCertificate, digest_list):
return False """
if key_pem: Check payload signature and return it.
return validateCertAndKey(cert_pem, key_pem)
return True Raises cryptography.exceptions.InvalidSignature if signature does not match
payload or if transmitted digest is not an acceptable one.
def sign(data, key, digest="sha256"): Note: does *not* verify received certificate itself (validity, issuer, ...).
""" """
Sign a data using digest and return signature. # Check whether given digest is allowed
digest = wrapped['digest'].encode('ascii')
if digest not in digest_list:
raise cryptography.exceptions.UnsupportedAlgorithm(
'%r is not in allowed digest list',
)
hash_class = getattr(hashes, digest.upper())
payload = json.loads(wrapped['payload'])
x509.load_pem_x509_certificate(
getCertificate(payload).encode('ascii'),
_cryptography_backend,
).public_key().verify(
wrapped['signature'].encode('ascii').decode('base64'),
wrapped['payload'].encode('utf-8') + digest + ' ',
padding.PSS(
mgf=padding.MGF1(hash_class()),
salt_length=padding.PSS.MAX_LENGTH,
),
hash_class(),
)
return payload
def nullUnwrap(wrapped):
""" """
pkey = crypto.load_privatekey(crypto.FILETYPE_PEM, key) Un-wrapp unsigned content. To onl be used on content received from
an authenticated user (and hence over a secure channel, HTTPS).
"""
assert wrapped['digest'] is None
return json.loads(wrapped['payload'])
sign = crypto.sign(pkey, data, digest) def load_ca_certificate(data):
#data_base64 = base64.b64encode(sign) """
Load CA certificate from PEM-encoded data.
return sign Raises CertificateVerificationError if loaded certificate is not self-signed
or is otherwise invalid.
"""
crt = x509.load_pem_x509_certificate(data, _cryptography_backend)
_verifyCertificateChain(crt, [crt], None)
return crt
def verify(data, cert_string, signature, digest="sha256"): def load_certificate(data, trusted_cert_list, crl):
""" """
Verify the signature for a data string. Load a certificate from PEM-encoded data.
cert_string: is the certificate content as string Raises CertificateVerificationError if loaded certificate is not signed by
signature: is generate using 'signData' from the data to verify any of trusted certificates, is revoked or is otherwise invalid.
data: content to verify
digest: by default is sha256, set the correct value
""" """
x509 = crypto.load_certificate(crypto.FILETYPE_PEM, cert_string) crt = x509.load_pem_x509_certificate(data, _cryptography_backend)
return crypto.verify(x509, signature, data, digest.encode("ascii", 'ignore')) _verifyCertificateChain(crt, trusted_cert_list, crl)
return crt
def wrap(payload, key, digest_list): def dump_certificate(data):
""" """
Sign payload (json-serialised) with key, using one of the given digests. Serialise a certificate as PEM-encoded data.
""" """
# Choose a digest between the ones supported return data.public_bytes(serialization.Encoding.PEM)
# how to choose the default digest ?
digest = digest_list[0]
payload = json.dumps(payload)
return {
"payload": payload,
"digest": digest,
"signature": sign(payload + digest + ' ', key, digest).encode('base64'),
}
def unwrap(wrapped, getCertificate, digest_list): def load_certificate_request(data):
""" """
Raise if signature does not match payload. Returns payload. Load a certificate request from PEM-encoded data.
Raises cryptography.exceptions.InvalidSignature if certificate signature
does not match embedded public key.
"""
result = x509.load_pem_x509_csr(data, _cryptography_backend)
if not result.is_signature_valid:
raise cryptography.exceptions.InvalidSignature
return result
def dump_certificate_request(data):
"""
Serialise acertificate request as PEM-encoded data.
"""
return data.public_bytes(
encoding=serialization.Encoding.PEM,
)
def load_privatekey(data):
"""
Load a private key from PEM-encoded data.
"""
return serialization.load_pem_private_key(
data,
password=None,
backend=_cryptography_backend,
)
def dump_privatekey(data):
"""
Serialise a private key as PEM-encoded data.
"""
return data.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption(),
)
def generatePrivateKey(key_len):
"""
Generate a new private key of specified length.
"""
return rsa.generate_private_key(
public_exponent=65537,
key_size=key_len,
backend=_cryptography_backend,
)
def load_crl(data, trusted_cert_list):
"""
Load a certificate revocation list from PEM-encoded data.
If cryptography supports it, raises cryptography.exceptions.InvalidSignature
if the CRL signature does not match any trusted certificate.
"""
crl = x509.load_pem_x509_crl(data, _cryptography_backend)
for cert in trusted_cert_list:
# TODO: make mandatory when next cryptography version is released
if getattr(crl, 'is_signature_valid', lambda x: True)(cert.public_key()):
return crl
raise cryptography.exceptions.InvalidSignature
EPOCH = datetime.datetime(1970, 1, 1)
def datetime2timestamp(value):
"""
Convert given datetime into a unix timestamp.
"""
return (value - EPOCH).total_seconds()
class SleepInterrupt(KeyboardInterrupt):
"""
A sleep was interrupted by a KeyboardInterrupt
"""
pass
def interruptibleSleep(duration):
"""
Like sleep, but raises SleepInterrupt when interrupted by KeyboardInterrupt
""" """
# Check whether given digest is allowed
if wrapped['digest'] not in digest_list:
raise BadSignature('Given digest is not supported')
payload = json.loads(wrapped['payload'])
crt = getCertificate(payload)
try: try:
verify(wrapped['payload'] + wrapped['digest'] + ' ', crt, wrapped['signature'].decode('base64'), wrapped['digest']) time.sleep(duration)
except crypto.Error, e: except KeyboardInterrupt:
raise BadSignature('Signature mismatch: %s' % str(e)) raise SleepInterrupt
return payload
def until(deadline):
"""
Call interruptibleSleep until deadline is reached.
"""
now = datetime.datetime.utcnow()
while now < deadline:
interruptibleSleep((deadline - now).total_seconds())
now = datetime.datetime.utcnow()
return now
# This file is part of caucase
# Copyright (C) 2017 Nexedi
# 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/>.
import logging
import os, errno
import argparse
import traceback
import json
import flask
from flask import (session, request, redirect, url_for, render_template,
jsonify, abort, send_file, flash, g, Response)
from flask_user import UserManager, SQLAlchemyAdapter
from wtforms import StringField, SubmitField, validators
from flask_wtf import FlaskForm
from flask_mail import Mail
from flask_user import login_required, current_user
from flask_login import logout_user #, login_user, current_user, login_required
from caucase.ca import CertificateAuthority, DEFAULT_DIGEST_LIST, MIN_CA_RENEW_PERIOD
from caucase.exceptions import (NoStorage, NotFound, Found, BadSignature,
BadCertificateSigningRequest,
BadCertificate,
CertificateVerificationError,
ExpiredCertificate)
from functools import wraps
from caucase import utils, app, db
class DisabledStringField(StringField):
def __call__(self, *args, **kwargs):
kwargs.setdefault('disabled', True)
return super(ReadonlyStringField, self).__call__(*args, **kwargs)
# Define the User profile form
class UserProfileForm(FlaskForm):
username = StringField('Username')
first_name = StringField('First name', validators=[
validators.DataRequired('First name is required')])
last_name = StringField('Last name', validators=[
validators.DataRequired('Last name is required')])
email = StringField('Email', validators=[
validators.DataRequired('Email is required')])
submit = SubmitField('Save')
def parseArguments(argument_list=[]):
"""
Parse arguments for Certificate Authority instance.
"""
parser = argparse.ArgumentParser()
parser.add_argument('--ca-dir',
help='Certificate authority base directory')
parser.add_argument('-H', '--host',
default='127.0.0.1',
help='Host or IP of ca server. Default: %(default)s')
parser.add_argument('-P', '--port',
default='9086', type=int,
help='Port for ca server. Default: %(default)s')
parser.add_argument('-d', '--debug',
action="store_true", dest="debug", default=False,
help='Enable debug mode.')
parser.add_argument('-l', '--log-file',
help='Path for log output')
parser.add_argument('--crt-life-time',
default=365*24*60*60, type=int,
help='The time in seconds before a generated certificate will expire. Default: 365*24*60*60 seconds (1 year)')
parser.add_argument('-s', '--subject',
default='',
help='Formatted subject string to put into generated CA Certificate file. Ex: /C=XX/ST=State/L=City/OU=OUnit/O=Company/CN=CAAuth/emailAddress=xx@example.com')
parser.add_argument('--ca-life-period',
default=10, type=float,
help='Number of individual certificate validity periods during which the CA certificate is valid. Default: %(default)s')
parser.add_argument('--ca-renew-period',
default=MIN_CA_RENEW_PERIOD, type=float,
help='Number of individual certificate validity periods during which both the existing and the new CA Certificates are valid. Default: %(default)s')
parser.add_argument('--crl-life-period',
default=1/50., type=float,
help='Number of individual certificate validity periods during which the CRL is valid. Default: %(default)s')
parser.add_argument('-D', '--digest',
action='append', dest='digest_list', default=DEFAULT_DIGEST_LIST,
help='Allowed digest for all signature. Default: %(default)s')
parser.add_argument('--max-request-amount',
default=50,
help='Maximun pending certificate signature request amount. Default: %(default)s')
parser.add_argument('--crt-keep-time',
default=30*24*60*60, type=int,
help='The time in seconds before a generated certificate will be deleted on CA server. Set 0 to never delete. Default: 30*24*60*60 seconds (30 days)')
parser.add_argument('--external-url',
help="The HTTP URL at which this tool's \"/\" path is reachable by all certificates users in order to retrieve latest CRL.")
parser.add_argument('--auto-sign-csr-amount',
default=1, type=int,
help='Say how many csr must be signed automatically. Has no effect if there is more than the specified value of csr submitted.')
if argument_list:
return parser.parse_args(argument_list)
return parser.parse_args()
def getLogger(debug=False, log_file=None):
logger = logging.getLogger("CertificateAuthority")
logger.setLevel(logging.INFO)
if not log_file:
logger.addHandler(logging.StreamHandler())
else:
handler = logging.FileHandler(log_file)
handler.setFormatter(logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s"))
logger.addHandler(handler)
logger.info('Configured logging to file %r' % log_file)
if debug:
logger.setLevel(logging.DEBUG)
return logger
def getConfig(self, key):
if key in self.keys():
temp_dict = dict()
temp_dict.update(self)
return temp_dict[key]
else:
raise KeyError
def start():
"""
Start Web Flask application server
"""
options = parseArguments()
configure_flask(options)
app.logger.info("Certificate Authority server started on http://%s:%s" % (
options.host, options.port))
app.run(
host=options.host,
port=int(options.port)
)
def configure_flask(options):
"""
Configure certificate authority service
"""
if not options.ca_dir:
options.ca_dir = os.getcwd()
else:
options.ca_dir = os.path.abspath(options.ca_dir)
if not options.external_url:
options.external_url = 'http://[%s]:%s' % (options.host, options.port)
db_file = "sqlite:///%s"% os.path.join(options.ca_dir, 'ca.db')
# work in ca directory
os.chdir(options.ca_dir)
# init Flask app
app.config.update(
DEBUG=options.debug,
CSRF_ENABLED=True,
USER_AFTER_LOGIN_ENDPOINT='manage_csr',
USER_AFTER_LOGOUT_ENDPOINT='index',
USER_ENABLE_USERNAME=True,
USER_ENABLE_EMAIL=False,
USER_ENABLE_REGISTRATION=False,
USER_ENABLE_CHANGE_USERNAME=False,
SECRET_KEY = 'This is an UNSECURE Secret. Please CHANGE THIS for production environments.',
SQLALCHEMY_DATABASE_URI=db_file
)
flask.config.Config.__getattr__ = getConfig
mail = Mail(app)
# Setup Flask-User
# XXX - User table Will go away when switching to CA for Users
if not app.config['TESTING']:
from caucase.storage import User
db_adapter = SQLAlchemyAdapter(db, User) # Register the User model
user_manager = UserManager(db_adapter, app) # Initialize Flask-User
logger = getLogger(options.debug, options.log_file)
app.logger.addHandler(logger)
# Instanciate storage
from caucase.storage import Storage
storage = Storage(db,
max_csr_amount=options.max_request_amount,
crt_keep_time=options.crt_keep_time,
csr_keep_time=options.crt_keep_time)
ca = CertificateAuthority(
storage=storage,
ca_life_period=options.ca_life_period,
ca_renew_period=options.ca_renew_period,
crt_life_time=options.crt_life_time,
crl_renew_period=options.crl_life_period,
digest_list=options.digest_list,
crl_base_url='%s/crl' % options.external_url,
ca_subject=options.subject,
auto_sign_csr_amount=options.auto_sign_csr_amount
)
# XXX - Storage argument Will go away when switching to CA for Users
app.config.update(
storage=storage,
ca=ca,
log_file=options.log_file,
)
def check_authentication(username, password):
user = app.config.storage.findUser(username)
if user:
return app.user_manager.verify_password(password, user)
else:
return False
def authenticated_method(func):
""" This decorator ensures that the current user is logged in before calling the actual view.
Abort with 401 when the user is not logged in."""
@wraps(func)
def decorated_view(*args, **kwargs):
# User must be authenticated
auth = request.authorization
if not auth:
return abort(401)
elif not check_authentication(auth.username, auth.password):
return abort(401)
# Call the actual view
return func(*args, **kwargs)
return decorated_view
class FlaskException(Exception):
status_code = 400
code = 400
def __init__(self, message, status_code=None, payload=None):
Exception.__init__(self)
self.message = message
if status_code is not None:
self.status_code = status_code
self.payload = payload
def to_dict(self):
rv = dict(self.payload or ())
# rv['code'] = self.code
rv['message'] = self.message
return rv
@app.errorhandler(FlaskException)
def handle_invalid_usage(error):
response = jsonify(error.to_dict())
response.status_code = error.status_code
return response
@app.errorhandler(401)
def error401(error):
if error.description is None:
message = {
'code': 401,
'name': 'Unauthorized',
'message': "Authenticate."
}
else:
message = error.description
response = jsonify(message)
response.status_code = 401
response.headers['WWW-Authenticate'] = 'Basic realm="Login Required"'
return response
@app.errorhandler(403)
def error403(error):
if error.description is None:
message = {
'code': 404,
'name': 'Forbidden',
'message': 'Forbidden. Your are not allowed to access %s' % request.url,
}
else:
message = error.description
response = jsonify(message)
response.status_code = 404
return response
@app.errorhandler(404)
def error404(error):
if error.description is None:
message = {
'code': 404,
'name': 'NotFound',
'message': 'Resource not found: ' + request.url,
}
else:
message = error.description
response = jsonify(message)
response.status_code = 404
return response
@app.errorhandler(400)
def error400(error):
if error.description is None:
message = {
'code': 400,
'name': 'BadRequest',
'message': 'The request could not be understood by the server, you probably provided wrong parameters.'
}
else:
message = error.description
response = jsonify(message)
response.status_code = 400
return response
def send_file_content(content, filename, mimetype='text/plain'):
return Response(content,
mimetype=mimetype,
headers={"Content-Disposition":
"attachment;filename=%s" % filename})
@app.before_request
def before_request():
# XXX - This function Will be modified or removed when switching to CA for Users
is_admin_path = request.path.startswith('/admin') or request.path.startswith('/user')
if not is_admin_path and not request.path.startswith('/certificates'):
return
if is_admin_path:
csr_count = app.config.storage.countPendingCertificateSiningRequest()
if csr_count < 10:
csr_count = '0%s' % csr_count
session['count_csr'] = csr_count
if request.path == '/admin/configure' or request.path == '/admin/setpassword':
# check if password file exists, if yes go to index
if app.config.storage.findUser('admin'):
return redirect(url_for('admin'))
return
# XXX - using hard username
if not app.config.storage.findUser('admin'):
return redirect(url_for('configure'))
g.user = current_user
# Routes for certificate Authority
@app.route('/crl', methods=['GET'])
def get_crl():
"""
Get the lastest CRL (certificate revocation list)
"""
crl_content = app.config.ca.getCertificateRevocationList()
return send_file_content(crl_content, 'ca.crl.pem')
@app.route('/csr/<string:csr_id>', methods=['GET'])
def get_csr(csr_id):
"""
Get a CSR string in PEM format from identified by `csr_id`.
"""
try:
csr_content = app.config.ca.getPendingCertificateRequest(csr_id)
except NotFound, e:
raise FlaskException(str(e),
status_code=404, payload={"name": "FileNotFound", "code": 1})
return send_file_content(csr_content, csr_id)
@app.route('/csr', methods=['PUT'])
def request_cert():
"""
Store certificate signature request (csr) in PEM format
"""
csr_content = request.form.get('csr', '').encode('utf-8')
if not csr_content:
raise FlaskException("'csr' parameter is mandatory",
payload={"name": "MissingParameter", "code": 2})
try:
csr_id = app.config.ca.createCertificateSigningRequest(csr_content)
except BadCertificateSigningRequest, e:
raise FlaskException(str(e),
payload={"name": "FileFormat", "code": 3})
except NoStorage, e:
raise FlaskException(str(e),
payload={"name": "NoStorage", "code": 4})
response = Response("", status=201)
response.headers['Location'] = url_for('get_csr', _external=True, csr_id=csr_id)
return response
@app.route('/csr/<string:csr_id>', methods=['DELETE'])
@authenticated_method
def remove_csr(csr_id):
"""
Delete a Certificate signature request. Authentication required
"""
try:
app.config.ca.deletePendingCertificateRequest(csr_id)
except NotFound, e:
raise FlaskException("%s" % str(e),
status_code=404, payload={"name": "FileNotFound", "code": 1})
response = Response("", status=200)
return response
@app.route('/crt/<string:cert_id>', methods=['GET'])
def get_crt(cert_id):
"""
Get a certificate by the id `cert_id`
"""
try:
cert_content = app.config.ca.getCertificate(cert_id)
except NotFound, e:
raise FlaskException("%s" % str(e),
status_code=404, payload={"name": "FileNotFound", "code": 1})
return send_file_content(cert_content, cert_id)
@app.route('/crt/serial/<string:serial>', methods=['GET'])
def crt_fromserial(serial):
"""
Get a certificate by the serial
"""
try:
cert_content = app.config.ca.getCertificateFromSerial(serial)
except NotFound, e:
raise FlaskException("%s" % str(e),
status_code=404, payload={"name": "FileNotFound", "code": 1})
return send_file_content(cert_content, '%s.crt.pem' % serial)
@app.route('/crt/ca.crt.pem', methods=['GET'])
def get_cacert():
"""
Get CA Certificate in PEM format string.
"""
ca_cert = app.config.ca.getCACertificate()
return send_file_content(ca_cert, 'ca.crt.pem')
@app.route('/crt/ca.crt.json', methods=['GET'])
def get_cacert_json():
"""
Return CA certificate chain list, if the CA certificate is being renewed
the list will contain the next certificate and the old certificate which
will expire soon.
"""
ca_chain_list = app.config.ca.getValidCACertificateChain()
return jsonify(ca_chain_list)
def signcert(csr_key, subject_dict=None, redirect_to=''):
try:
cert_id = app.config.ca.createCertificate(csr_key, subject_dict=subject_dict)
except NotFound, e:
raise FlaskException("%s" % str(e),
status_code=404, payload={"name": "FileNotFound", "code": 1})
except Found, e:
# Certificate is found
raise FlaskException("%s" % str(e),
payload={"name": "FileFound", "code": 5})
# XXX - to remove (flask UI)
flash('Certificate is signed!', 'success')
if redirect_to:
return redirect(url_for(redirect_to))
response = Response("", status=201)
response.headers['Location'] = url_for('get_crt', _external=True, cert_id=cert_id)
return response
@app.route('/crt', methods=['PUT'])
@authenticated_method
def sign_cert():
"""
Sign a certificate, require authentication
"""
key = request.form.get('csr_id', '').encode('utf-8')
if not key:
raise FlaskException("'csr_id' parameter is a mandatory parameter",
payload={"name": "MissingParameter", "code": 2})
try:
subject = request.form.get('subject', '').encode('utf-8')
subject_dict = None
if subject:
subject_dict = json.loads(subject)
return signcert(key, subject_dict=subject_dict)
except ValueError, e:
traceback.print_exc()
raise FlaskException(str(e),
payload={"name": "FileFormat", "code": 3})
except AttributeError, e:
raise FlaskException(str(e),
payload={"name": "FileFormat", "code": 3})
@app.route('/crt/renew', methods=['PUT'])
def renew_cert():
"""
this method is used to renew expired certificate.
"""
payload = request.form.get('payload', '')
if not payload:
# Bad parameters
raise FlaskException("'payload' parameter is mandatory",
payload={"name": "MissingParameter", "code": 2})
try:
wrapped = json.loads(payload)
except ValueError, e:
raise FlaskException("payload parameter is not a valid Json string: %s" % str(e),
payload={"name": "FileFormat", "code": 3})
try:
cert_id = app.config.ca.renew(wrapped)
except ValueError, e:
traceback.print_exc()
raise FlaskException(str(e),
payload={"name": "FileFormat", "code": 3})
except KeyError, e:
traceback.print_exc()
raise FlaskException(str(e),
payload={"name": "FileFormat", "code": 3})
except BadSignature, e:
raise FlaskException(str(e),
payload={"name": "SignatureMismatch", "code": 6})
except BadCertificateSigningRequest, e:
raise FlaskException(str(e),
payload={"name": "FileFormat", "code": 3})
except BadCertificate, e:
raise FlaskException(str(e),
payload={"name": "FileFormat", "code": 3})
except CertificateVerificationError, e:
raise FlaskException(str(e),
payload={"name": "SignatureMismatch", "code": 6})
except NoStorage, e:
raise FlaskException(str(e),
payload={"name": "NoStorage", "code": 4})
except NotFound, e:
raise FlaskException(str(e),
status_code=404, payload={"name": "FileNotFound", "code": 1})
except Found, e:
# Certificate is found
raise FlaskException(str(e),
payload={"name": "FileFound", "code": 5})
response = Response("", status=201)
response.headers['Location'] = url_for('get_crt', _external=True, cert_id=cert_id)
return response
@app.route('/crt/revoke', methods=['PUT'])
def request_revoke_crt():
"""
Revoke method existing and valid certificate
"""
payload = request.form.get('payload', '')
if not payload:
# Bad parameters
raise FlaskException("'payload' parameter is mandatory",
payload={"name": "MissingParameter", "code": 2})
try:
wrapped = json.loads(payload)
except ValueError, e:
raise FlaskException("payload parameter is not a valid Json string: %s" % str(e),
payload={"name": "FileFormat", "code": 3})
try:
app.config.ca.revokeCertificate(wrapped)
except ValueError, e:
traceback.print_exc()
raise FlaskException(str(e),
payload={"name": "FileFormat", "code": 3})
except KeyError, e:
traceback.print_exc()
raise FlaskException(str(e),
payload={"name": "FileFormat", "code": 3})
except BadSignature, e:
raise FlaskException(str(e),
payload={"name": "SignatureMismatch", "code": 6})
except CertificateVerificationError, e:
raise FlaskException(str(e),
payload={"name": "SignatureMismatch", "code": 6})
except BadCertificate, e:
raise FlaskException(str(e),
payload={"name": "FileFormat", "code": 3})
except ExpiredCertificate, e:
raise FlaskException(str(e),
payload={"name": "FileFormat", "code": 3})
except NotFound, e:
raise FlaskException(str(e),
status_code=404, payload={"name": "FileNotFound", "code": 1})
response = Response("", status=201, )
return response
@app.route('/crt/revoke/id', methods=['PUT'])
@authenticated_method
def revoke_crt():
"""
Directly revoke a certificate from his serial
"""
try:
crt_id = request.form.get('crt_id', '')
if not crt_id:
raise FlaskException("'crt_id' parameter is mandatory",
payload={"name": "MissingParameter", "code": 2})
app.config.ca.revokeCertificateFromID(crt_id)
except ValueError, e:
traceback.print_exc()
raise FlaskException(str(e),
payload={"name": "FileFormat", "code": 3})
except ExpiredCertificate, e:
raise FlaskException(str(e),
payload={"name": "FileFormat", "code": 3})
except NotFound, e:
raise FlaskException(str(e),
status_code=404, payload={"name": "FileNotFound", "code": 1})
response = Response("", status=201)
return response
#Manage routes (Authentication required) - Flask APP
# XXX - this routes will be updated or removed after implement ca_user
@app.route('/')
def home():
return redirect(url_for('index'))
@app.route('/certificates')
def index():
# page to list certificates, also connection link
data_list = app.config.ca.getSignedCertificateList()
return render_template("index.html", data_list=data_list)
@app.route('/admin/logout')
def logout():
logout_user()
return redirect(url_for('index'))
@app.route('/admin/')
@login_required
def admin():
return index()
@app.route('/admin/csr_requests', methods=['GET'])
@login_required
def manage_csr():
data_list = app.config.ca.getPendingCertificateRequestList()
return render_template('manage_page.html', data_list=data_list)
@app.route('/admin/configure', methods=['GET'])
def configure():
return render_template("configure.html")
@app.route('/admin/setpassword', methods=['POST'])
def setpassword():
username = 'admin'
password = request.form.get('password', '').encode('utf-8')
if not password:
raise FlaskException("'password' parameter is mandatory",
payload={"name": "MissingParameter", "code": 2})
app.config.storage.findOrCreateUser(
"Admin",
"admin",
"admin@example.com",
username,
app.user_manager.hash_password(password))
logout_user()
return redirect(url_for('manage_csr'))
@app.route('/admin/signcert', methods=['GET'])
@login_required
def do_signcert_web():
csr_id = request.args.get('csr_id', '').encode('utf-8')
if not csr_id:
raise FlaskException("'csr_id' parameter is a mandatory parameter",
payload={"name": "MissingParameter", "code": 2})
try:
return signcert(csr_id, subject_dict=None, redirect_to='manage_csr')
except ValueError, e:
raise FlaskException(str(e),
payload={"name": "FileFormat", "code": 3})
@app.route('/admin/deletecsr', methods=['GET'])
@login_required
def deletecsr():
"""
Delete certificate signature request file
"""
csr_id = request.args.get('csr_id', '').encode('utf-8')
if not csr_id:
raise FlaskException("'csr_id' parameter is a mandatory parameter",
payload={"name": "MissingParameter", "code": 2})
try:
app.config.ca.deletePendingCertificateRequest(csr_id)
except NotFound, e:
raise FlaskException("%s" % str(e),
status_code=404, payload={"name": "FileNotFound", "code": 1})
return redirect(url_for('manage_csr'))
@app.route('/admin/profile', methods=['GET', 'POST'])
@login_required
def user_profile():
form = UserProfileForm(request.form, obj=current_user)
if request.method == 'POST' and form.validate():
# Copy form fields to user_profile fields
del form.username # revove username from recieved form
form.populate_obj(current_user)
db.session.commit()
# Redirect to home page
return redirect(url_for('index'))
return render_template('user_profile.html',
form=form)
...@@ -15,42 +15,356 @@ ...@@ -15,42 +15,356 @@
# #
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with caucase. If not, see <http://www.gnu.org/licenses/>. # 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 httplib
import json
import traceback
import sys
from . import utils
from . import exceptions
__all__ = ('Application', )
import os def _getStatus(code):
from caucase import app return '%i %s' % (code, httplib.responses[code])
from caucase.web import parseArguments, configure_flask
from werkzeug.contrib.fixers import ProxyFix
def readConfigFromFile(config_file): class ApplicationError(Exception):
config_list = [] """
with open(config_file) as f: WSGI HTTP error base class.
for line in f.readlines(): """
if not line or line.startswith('#'): status = _getStatus(httplib.INTERNAL_SERVER_ERROR)
continue response_headers = ()
line_list = line.strip().split(' ')
if len(line_list) == 1: class BadRequest(ApplicationError):
config_list.append('--%s' % line_list[0].strip()) """
elif len(line_list) > 1: HTTP bad request error
config_list.append('--%s' % line_list[0].strip()) """
config_list.append(' '.join(line_list[1:])) status = _getStatus(httplib.BAD_REQUEST)
class NotFound(ApplicationError):
"""
HTTP not found error
"""
status = _getStatus(httplib.NOT_FOUND)
class BadMethod(ApplicationError):
"""
HTTP bad method error
"""
status = _getStatus(httplib.METHOD_NOT_ALLOWED)
class Conflict(ApplicationError):
"""
HTTP conflict
"""
status = _getStatus(httplib.CONFLICT)
class TooLarge(ApplicationError):
"""
HTTP too large error
"""
status = _getStatus(httplib.REQUEST_ENTITY_TOO_LARGE)
class InsufficientStorage(ApplicationError):
"""
No storage slot available (not necessarily out of disk space)
"""
# httplib lacks the textual description for 507, although it has the constant...
status = '%i Insufficient Storage' % (httplib.INSUFFICIENT_STORAGE, )
STATUS_OK = _getStatus(httplib.OK)
STATUS_CREATED = _getStatus(httplib.CREATED)
STATUS_NO_CONTENT = _getStatus(httplib.NO_CONTENT)
MAX_BODY_LENGTH = 10 * 1024 * 1024 # 10 MB
class Application(object):
"""
WSGI application class
Thread- and process-safe (locks handled by sqlite3).
"""
def __init__(self, cau, cas):
"""
cau (caucase.ca.CertificateAuthority)
CA for users.
Will be hosted under /cau
return parseArguments(config_list) cas (caucase.ca.CertificateAuthority)
CA for services.
Will be hosted under /cas
"""
self._cau = cau
self._cas = cas
def start_wsgi(): def __call__(self, environ, start_response):
""" """
Start entry for wsgi, do not run app.run, read config from file WSGI entry point
""" """
if os.environ.has_key('CA_CONFIGURATION_FILE'): try: # Convert ApplicationError subclasses in error responses
config_file = os.environ['CA_CONFIGURATION_FILE'] try: # Convert non-wsgi exceptions into WSGI exceptions
path_item_list = [x for x in environ['PATH_INFO'].split('/') if x]
try:
context_id, method_id = path_item_list[:2]
except ValueError:
raise NotFound
if context_id == 'cau':
context = self._cau
elif context_id == 'cas':
context = self._cas
else:
raise NotFound
if method_id.startswith('_'):
raise NotFound
try:
method = getattr(self, method_id)
except AttributeError:
raise NotFound
status, header_list, result = method(context, environ, path_item_list[2:])
except ApplicationError:
raise
except exceptions.NotFound:
raise NotFound
except exceptions.Found:
raise Conflict
except exceptions.NoStorage:
raise InsufficientStorage
except exceptions.CertificateAuthorityException, e:
raise BadRequest(str(e))
except Exception:
print >>sys.stderr, 'Unhandled exception',
traceback.print_exc(file=sys.stderr)
raise ApplicationError
except ApplicationError, e:
start_response(
e.status,
list(e.response_headers),
)
result = [str(x) for x in e.args]
else: else:
config_file = 'ca.conf' start_response(
status,
header_list,
)
return result
@staticmethod
def _read(environ):
"""
Read the entire request body.
configure_flask(readConfigFromFile(config_file)) Raises BadRequest if request Content-Length cannot be parsed.
Raises TooLarge if Content-Length if over MAX_BODY_LENGTH.
If Content-Length is not set, reads at most MAX_BODY_LENGTH bytes.
"""
try:
length = int(environ.get('CONTENT_LENGTH', MAX_BODY_LENGTH))
except ValueError:
raise BadRequest('Invalid Content-Length')
if length > MAX_BODY_LENGTH:
raise TooLarge('Content-Length limit exceeded')
return environ['wsgi.input'].read(length)
def _authenticate(self, environ):
"""
Verify user authentication.
Raises NotFound if authentication does not pass checks.
"""
# Note on NotFound usage here: HTTP specs do not describe how to request
# client to provide transport-level authentication mechanism (x509 cert)
# so 401 is not applicable. 403 is not applicable either as spec requests
# client to not retry the request. 404 is recommended when server does not
# wish to disclose the reason why it rejected the access, so let's use
# this.
try:
ca_list = self._cau.getCACertificateList()
utils.load_certificate(
environ.get('SSL_CLIENT_CERT', b''),
trusted_cert_list=ca_list,
crl=utils.load_crl(
self._cau.getCertificateRevocationList(),
ca_list,
),
)
except (exceptions.CertificateVerificationError, ValueError):
raise NotFound
def _readJSON(self, environ):
"""
Read request body and convert to json object.
Raises BadRequest if request Content-Type is not 'application/json', or if
json decoding fails.
"""
if environ.get('CONTENT_TYPE') != 'application/json':
raise BadRequest('Bad Content-Type')
data = self._read(environ)
try:
return json.loads(data)
except ValueError:
raise BadRequest('Invalid json')
app.wsgi_app = ProxyFix(app.wsgi_app) @staticmethod
def crl(context, environ, subpath):
"""
Handle /{context}/crl urls.
"""
if subpath:
raise NotFound
if environ['REQUEST_METHOD'] != 'GET':
raise BadMethod
data = context.getCertificateRevocationList()
return (
STATUS_OK,
[
('Content-Type', 'application/pkix-crl'),
('Content-Length', str(len(data))),
],
[data],
)
app.logger.info("Certificate Authority server ready...") def csr(self, context, environ, subpath):
"""
Handle /{context}/csr urls.
"""
http_method = environ['REQUEST_METHOD']
if http_method == 'GET':
if subpath:
try:
csr_id, = subpath
except ValueError:
raise NotFound
try:
csr_id = int(csr_id)
except ValueError:
raise BadRequest('Invalid integer')
data = context.getCertificateSigningRequest(csr_id)
content_type = 'application/pkcs10'
else:
self._authenticate(environ)
data = json.dumps(context.getCertificateRequestList())
content_type = 'application/json'
return (
STATUS_OK,
[
('Content-Type', content_type),
('Content-Length', str(len(data))),
],
[data],
)
elif http_method == 'PUT':
if subpath:
raise NotFound
csr_id = context.appendCertificateSigningRequest(self._read(environ))
return (
STATUS_CREATED,
[
('Location', str(csr_id)),
],
[],
)
elif http_method == 'DELETE':
try:
csr_id, = subpath
except ValueError:
raise NotFound
self._authenticate(environ)
try:
context.deletePendingCertificateSigningRequest(csr_id)
except exceptions.NotFound:
raise NotFound
return (STATUS_NO_CONTENT, [], [])
else:
raise BadMethod
if __name__ == 'caucase.wsgi': def crt(self, context, environ, subpath):
start_wsgi() """
Handle /{context}/crt urls.
"""
http_method = environ['REQUEST_METHOD']
try:
crt_id, = subpath
except ValueError:
raise NotFound
if http_method == 'GET':
if crt_id == 'ca.crt.pem':
data = context.getCACertificate()
content_type = 'application/pkix-cert'
elif crt_id == 'ca.crt.json':
data = json.dumps(context.getValidCACertificateChain())
content_type = 'application/json'
else:
try:
crt_id = int(crt_id)
except ValueError:
raise BadRequest('Invalid integer')
data = context.getCertificate(crt_id)
content_type = 'application/pkix-cert'
return (
STATUS_OK,
[
('Content-Type', content_type),
('Content-Length', str(len(data))),
],
[data],
)
elif http_method == 'PUT':
if crt_id == 'renew':
payload = utils.unwrap(
self._readJSON(environ),
lambda x: x['crt_pem'],
context.digest_list,
)
data = context.renew(
crt_pem=payload['crt_pem'].encode('ascii'),
csr_pem=payload['renew_csr_pem'].encode('ascii'),
)
return (
STATUS_OK,
[
('Content-Type', 'application/pkix-cert'),
('Content-Length', str(len(data))),
],
[data],
)
elif crt_id == 'revoke':
data = self._readJSON(environ)
if data['digest'] is None:
self._authenticate(environ)
payload = utils.nullUnwrap(data)
if 'revoke_crt_pem' not in payload:
context.revokeSerial(payload['revoke_serial'])
return (STATUS_NO_CONTENT, [], [])
else:
payload = utils.unwrap(
data,
lambda x: x['revoke_crt_pem'],
context.digest_list,
)
context.revoke(
crt_pem=payload['revoke_crt_pem'].encode('ascii'),
)
return (STATUS_NO_CONTENT, [], [])
else:
try:
crt_id = int(crt_id)
except ValueError:
raise BadRequest('Invalid integer')
body = self._read(environ)
if not body:
template_csr = None
elif environ.get('CONTENT_TYPE') == 'application/pkcs10':
template_csr = utils.load_certificate_request(body)
else:
raise BadRequest('Bad Content-Type')
self._authenticate(environ)
context.createCertificate(
csr_id=crt_id,
template_csr=template_csr,
)
return (STATUS_NO_CONTENT, [], [])
else:
raise BadMethod
...@@ -9,10 +9,10 @@ autonumber ...@@ -9,10 +9,10 @@ autonumber
== Signing Request Submission == == Signing Request Submission ==
User -> Authority : POST the CRL User -> Authority : PUT /csr with the CSR as body
alt Sining request passes all checks alt CSR passes format check
Authority --> User : Signing request identifier Authority --> User : Request identifier
else else CSR format invalid
Authority --> User : Error Authority --> User : Error
end end
Note over User : See "Certificate Retrieval" Note over User : See "Certificate Retrieval"
...@@ -20,18 +20,18 @@ Note over User : See "Certificate Retrieval" ...@@ -20,18 +20,18 @@ Note over User : See "Certificate Retrieval"
== Certificate Production == == Certificate Production ==
Note over Trusted : See "Signing Request Submission" Note over Trusted : See "Signing Request Submission"
Trusted -> Authority : GET (optional: with result range expression) Trusted -> Authority : GET /csr
Authority --> Trusted : List of pending signing requests with their identifiers Authority --> Trusted : List of pending signing requests with their identifiers
Trusted -> Authority : GET with request identifier Trusted -> Authority : GET /csr/<request identifier>
Authority --> Trusted : Signing request content Authority --> Trusted : CSR
alt Trusted agrees to prvoduce a signed certificate from the signing request alt Trusted agrees to produce a signed certificate from the signing request
Trusted -> Authority : POST with the signing request identifier Trusted -> Authority : PUT /crt/<request identifier>
alt Sining request was still pending alt CSR was still pending
Authority --> Trusted : Success Authority --> Trusted : Success
else else CSR not pending (deleted or already signed)
Authority --> Trusted : Not found Authority --> Trusted : Not found
end end
else else Trusted refuses to sign the request
Trusted -> Authority : DELETE with the signing request identifier Trusted -> Authority : DELETE with the signing request identifier
Authority --> Trusted : Ok Authority --> Trusted : Ok
end end
...@@ -39,16 +39,16 @@ end ...@@ -39,16 +39,16 @@ end
== Certificate Retrieval == == Certificate Retrieval ==
loop Until certificate obtained or request rejected loop Until certificate obtained or request rejected
User -> Authority : GET with signing request identifier User -> Authority : GET /crt/<request identifier>
alt Signing request was signed alt CRT exists
Authority --> User : Certificate content Authority --> User : Certificate content
else else CRT does not exist
Authority --> User : Not found Authority --> User : Not found
opt User wants to check request is still pending opt User checks if the CSR was rejected
User -> Authority : GET with signing request identifier User -> Authority : GET /csr/<request identifier>
alt Sining request is still pending alt CSR still pending
Authority --> User : Signing request content Authority --> User : Signing request content
else else CSR rejected
Authority --> User : Not found Authority --> User : Not found
end end
end end
...@@ -57,25 +57,43 @@ end ...@@ -57,25 +57,43 @@ end
== Certificate Renewal == == Certificate Renewal ==
User -> Authority : POST with the still-valid CRT and a CRL User -> Authority : PUT /crt/renew with the still-valid CRT and a CRL with the new public key
alt Renewal parameters consistent alt CRT is still valid (validity period, not revoked)
Authority --> User : signing request identifier Authority --> User : New certificate content
else else CRT invalid
Authority --> User : Error Authority --> User : Error
end end
Note over User : See "Certificate Retrieval"
== Certificate Revocation == == Certificate Revocation ==
User -> Authority : POST with the CRT User -> Authority : PUT /crt/revoke with the CRT, order signed with its private key
alt Revocation parameters consistent alt CRT is valid and parameters consistent
Authority --> User : Ok Authority --> User : CRT revoked
else else CRT is invalid or parameters inconsistent
Authority --> User : Error Authority --> User : Error
end end
== Certificate Revocation without access to private key ==
Trusted -> Authority : PUT /crt/revoke with the CRT
alt CRT is valid
Authority --> Trusted : CRT revoked
else CRT is invalid
Authority --> Trusted : Error
end
== Certificate Revocation without access to private key or the certificate ==
Note over Trusted: This procedure is discouraged as revoked certificate will linger for much longer\nthan strictly needed in the CRL, but useful when only the bare minimum\nis know about the certificate which revocation is desired.
Trusted -> Authority : PUT /crt/revoke with the serial to revoke
alt Serial is not revoked yet
Authority --> Trusted : CRT revoked
else Serials is already revoked
Authority --> Trusted : Error
end
== Certificate Validity Check == == Certificate Validity Check ==
Service -> Authority : GET (optional: with OCSP parameter) Service -> Authority : GET /crl
Authority --> Service : Certificate revocation list Authority --> Service : CRL content
@enduml @enduml
swagger: '2.0' swagger: '2.0'
info: info:
title: Certificate Authority title: caucase
description: Automated x509 certificate authority for use with non-http services description: Certificate Authority for Users, Certificate Authority for SErvices
version: 0.0.1 version: 0.2.0
contact: contact:
name: Vincent Pelletier (Nexedi) name: Vincent Pelletier (Nexedi)
url: 'http://www.nexedi.com' url: 'http://www.nexedi.com'
...@@ -13,33 +13,29 @@ schemes: ...@@ -13,33 +13,29 @@ schemes:
- https - https
consumes: consumes:
- application/json - application/json
- application/pkcs10
produces: produces:
- application/xml
- application/json - application/json
- application/pkix-cert - application/pkix-cert
- application/pkix-crl
- application/pkcs10
- application/x-x509-ca-cert - application/x-x509-ca-cert
tags: tags:
- name: todo - name: auth
description: Known as not fully specified yet description: https client authentication required
paths: paths:
/csr: /csr:
get: get:
tags: summary: List pending certificate signing requests
- todo
summary: List pending certificate signing requests - XXX PROPFIND, not GET !
operationId: getPendingCertificateRequestList operationId: getPendingCertificateRequestList
schemes: tags:
- https - auth
parameters: produces:
- $ref: '#/parameters/x-auth' - application/json
responses: responses:
'401': '200':
$ref: '#/responses/401' description: OK - CSR list returned
'403': '404':
$ref: '#/responses/403' $ref: '#/responses/404'
'500':
$ref: '#/responses/500'
put: put:
summary: Request a new certificate signature summary: Request a new certificate signature
operationId: createCertificateSigningRequest operationId: createCertificateSigningRequest
...@@ -54,32 +50,21 @@ paths: ...@@ -54,32 +50,21 @@ paths:
Location: Location:
description: URL of created resource description: URL of created resource
type: string type: string
'400':
$ref: '#/responses/400'
'500':
$ref: '#/responses/500'
'507': '507':
$ref: '#/responses/507' $ref: '#/responses/507'
/csr/{crt-id}: /csr/{crt-id}:
delete: delete:
summary: Reject a pending certificate signing request summary: Reject a pending certificate signing request
operationId: deletePendingCertificateRequest operationId: deletePendingCertificateRequest
schemes: tags:
- https - auth
parameters: parameters:
- $ref: '#/parameters/x-auth'
- $ref: '#/parameters/crt-id' - $ref: '#/parameters/crt-id'
responses: responses:
'204': '204':
description: No Content - CSR was rejected description: No Content - CSR was successfuly rejected
'401':
$ref: '#/responses/401'
'403':
$ref: '#/responses/403'
'404': '404':
$ref: '#/responses/404' $ref: '#/responses/404'
'500':
$ref: '#/responses/500'
get: get:
summary: Retrieve a pending certificate signing request summary: Retrieve a pending certificate signing request
operationId: getCertificateSigningRequest operationId: getCertificateSigningRequest
...@@ -90,37 +75,21 @@ paths: ...@@ -90,37 +75,21 @@ paths:
responses: responses:
'200': '200':
description: OK - CSR retrieved description: OK - CSR retrieved
'400':
$ref: '#/responses/400'
'404': '404':
$ref: '#/responses/404' $ref: '#/responses/404'
'500': /crt/{crt-id}:
$ref: '#/responses/500'
/crt:
put: put:
summary: Accept pending certificate signing request summary: Accept pending certificate signing request
operationId: createCertificate operationId: createCertificate
schemes: tags:
- https - auth
consumes:
- application/pkcs10
parameters:
- $ref: '#/parameters/x-auth'
- $ref: '#/parameters/csr'
responses: responses:
'201': '204':
description: Created - Certificate was signed description: No Content - CSR was successfuly signed
headers:
Location:
description: URL of created resource
type: string
'401':
$ref: '#/responses/401'
'404': '404':
$ref: '#/responses/404' $ref: '#/responses/404'
'403':
$ref: '#/responses/403'
'500':
$ref: '#/responses/500'
/crt/{crt-id}:
get: get:
summary: Retrieve a signed certificate summary: Retrieve a signed certificate
operationId: getCertificate operationId: getCertificate
...@@ -133,81 +102,62 @@ paths: ...@@ -133,81 +102,62 @@ paths:
description: OK - CRT retrieved description: OK - CRT retrieved
'404': '404':
$ref: '#/responses/404' $ref: '#/responses/404'
'500': /crt/ca.crt.pem:
$ref: '#/responses/500' get:
summary: Retrieve current CA certificate
operationId: getCACertificate
produces:
- application/x-x509-ca-cert
responses:
'200':
description: OK - CA CRT retrieved
/crt/ca.crt.json:
get:
summary: Retrieve current CA certificate trust chain
description: Response schema is described separately.
operationId: getCACertificateChain
produces:
- application/json
responses:
'200':
description: OK - CA CRT chain retrieved
/crt/revoke: /crt/revoke:
put: put:
summary: Revoke a certificate summary: Revoke a certificate
description: > description: Signed operation payload schema is described separately.
Response status 400 may mean operation could not be authenticated (signature hash mechanism not supported by server, signature did not match...). Signed operation payload schema is described separately.
operationId: revokeCertificate operationId: revokeCertificate
consumes: consumes:
- application/json - application/json
parameters: parameters:
- $ref: '#/parameters/signed-operation' - $ref: '#/parameters/signed-operation'
responses: responses:
'201': '204':
description: Created - Signing request was revoked description: No Content - certificate revoked
headers:
Location:
description: URL of created resource
type: string
'400':
$ref: '#/responses/400'
'500':
$ref: '#/responses/500'
/crt/renew: /crt/renew:
put: put:
summary: Renew a certificate summary: Renew a certificate
description: > description: Signed operation payload schema is described separately.
Response status 400 may mean operation could not be authenticated (signature hash mechanism not supported by server, signature did not match...), in addition to createCertificateSigningRequest status 400 reasons. Signed operation payload schema is described separately.
operationId: renewCertificate operationId: renewCertificate
consumes: consumes:
- application/json - application/json
parameters: parameters:
- $ref: '#/parameters/signed-operation' - $ref: '#/parameters/signed-operation'
responses: responses:
'201': '200':
description: Created - Renewwal request was accepted description: OK - Renewed certificate retrieved
headers:
Location:
description: URL of created resource
type: string
'400':
$ref: '#/responses/400'
'500':
$ref: '#/responses/500'
/crl: /crl:
get: get:
summary: Retrieve latest certificate revocation list summary: Retrieve latest certificate revocation list
operationId: getCertificateRevocationList operationId: getCertificateRevocationList
produces: produces:
- application/x-x509-ca-cert - application/pkix-crl
responses: responses:
'200': '200':
description: OK - CRL retrieved description: OK - CRL retrieved
'500':
$ref: '#/responses/500'
definitions: definitions:
csr: csr:
type: string type: string
description: application/pkcs10 data description: application/pkcs10 data
Error:
type: object
required:
- code
- name
- message
properties:
code:
type: integer
description: application-specific error code
name:
type: string
description: the explicit error name
message:
type: string
description: describes the error
signed-operation: signed-operation:
type: object type: object
required: required:
...@@ -231,12 +181,6 @@ parameters: ...@@ -231,12 +181,6 @@ parameters:
description: Opaque certificate signing request identifier description: Opaque certificate signing request identifier
required: true required: true
type: string type: string
x-auth:
name: Authorization
in: header
description: Credentials for this request
type: string
required: true
csr: csr:
name: csr name: csr
in: body in: body
...@@ -253,25 +197,7 @@ parameters: ...@@ -253,25 +197,7 @@ parameters:
responses: responses:
'400': '400':
description: Bad Request - you probably provided wrong parameters description: Bad Request - you probably provided wrong parameters
schema:
$ref: '#/definitions/Error'
'401':
description: Unauthorized - No Authorization header provided
headers:
Authenticate:
type: string
description: Requests that client provides credentials
schema:
$ref: '#/definitions/Error'
'403':
description: Forbidden - Authorization headed provided, but does not grant access
schema:
$ref: '#/definitions/Error'
'404': '404':
description: Not Found - Requested resource does not exist description: Not Found - Requested resource does not exist, or you did not provide required transport-level credentials (x509 cert over https)
schema:
$ref: '#/definitions/Error'
'500':
description: Internal Server Error
'507': '507':
description: Insufficient Storage description: Insufficient Storage
...@@ -20,60 +20,49 @@ from setuptools import setup, find_packages ...@@ -20,60 +20,49 @@ from setuptools import setup, find_packages
import glob import glob
import os import os
version = '0.1.4'
name = 'caucase'
long_description = open("README.rst").read() + "\n" long_description = open("README.rst").read() + "\n"
for f in sorted(glob.glob(os.path.join('caucase', 'README.*.rst'))): for f in sorted(glob.glob(os.path.join('caucase', 'README.*.rst'))):
long_description += '\n' + open(f).read() + '\n' long_description += '\n' + open(f).read() + '\n'
# long_description += open("CHANGES.txt").read() + "\n" # long_description += open("CHANGES.txt").read() + "\n"
# Provide a way to install additional requirements setup(
additional_install_requires = [] name='caucase',
try: version='0.9.0',
import argparse
except ImportError:
additional_install_requires.append('argparse')
setup(name=name,
version=version,
description="Certificate Authority.", description="Certificate Authority.",
long_description=long_description, long_description=long_description,
classifiers=[ classifiers=[
"Programming Language :: Python", 'Environment :: Console',
'Environment :: Web Environment',
'Intended Audience :: System Administrators',
'Intended Audience :: Information Technology',
'License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)',
'Topic :: Security :: Cryptography',
'Topic :: System :: Systems Administration :: Authentication/Directory',
'Topic :: Internet :: WWW/HTTP :: WSGI :: Application',
], ],
keywords='certificate authority', keywords='certificate authority',
url='https://lab.nexedi.com/nexedi/caucase', url='https://lab.nexedi.com/nexedi/caucase',
license='GPLv3', license='GPLv3+',
namespace_packages=['caucase'],
packages=find_packages(), packages=find_packages(),
include_package_data=True,
install_requires=[ install_requires=[
'Flask', # needed by servers 'cryptography', # everything x509 except...
'flask_user', 'pyOpenSSL', # ...certificate chain validation
'Flask-AlchemyDumps',
'setuptools', # namespaces
'pyOpenSSL', # manage ssl certificates
'pyasn1', # ASN.1 types and codecs for certificates
'pyasn1-modules',
'requests', # http requests
'pem', # Parse PEM files 'pem', # Parse PEM files
] + additional_install_requires,
extras_require = {
},
tests_require = [
'Flask-Testing',
], ],
zip_safe=False, # proxy depends on Flask, which has issues with tests_require=[
# accessing templates 'coverage',
'pylint',
],
zip_safe=True,
entry_points={ entry_points={
'console_scripts': [ 'console_scripts': [
'caucase = caucase.web:start', 'caucase = caucase.cli:main',
'caucase-cli = caucase.cli:main', 'caucase-probe = caucase.cli:probe',
'caucase-cliweb = caucase.cli_flask:main', 'caucase-monitor = caucase.cli:monitor',
'caucase-rerequest = caucase.cli:rerequest',
'caucase-key-id = caucase.cli:key_id',
'caucased = caucase.http:main',
] ]
}, },
test_suite='caucase.test', test_suite='caucase.test',
) )
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment