Commit ecd07d22 by 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
1 parent 0da27fdc
[run]
branch = true
concurrency =
thread
multiprocessing
parallel = true
/htmlcov/
/cover/
/.eggs/
/.coverage
.*.swp
*.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)
==================
* caucase web parameter 'auto-sign-csr-amount' can be used to set how many csr must be signed automatically.
......
include CHANGES.txt
recursive-include caucase/templates *.html
recursive-include caucase/static *.css *.png *.js *.gif
include COPYING
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 @@
#
# 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__)
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
"""
Caucase - Certificate Authority for Users, Certificate Authority for SErvices
"""
# 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 @@
#
# 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
"""
class CertificateAuthorityException(Exception):
"""Base exception"""
......@@ -29,23 +32,9 @@ class NotFound(CertificateAuthorityException):
pass
class Found(CertificateAuthorityException):
"""Requested ID is already in use"""
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"""
"""Resource to create already exists"""
pass
class CertificateVerificationError(CertificateAuthorityException):
"""Certificate is not valid, it was not signed by CA"""
pass
class ExpiredCertificate(CertificateAuthorityException):
"""Certificate has expired and could not be used"""
pass
\ No newline at end of file
.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(){