mirror of
https://github.com/laurivosandi/certidude
synced 2026-01-12 17:06:59 +00:00
Refactor wrappers
Completely remove wrapper class for CA, use certidude.authority module instead.
This commit is contained in:
98
certidude/api/__init__.py
Normal file
98
certidude/api/__init__.py
Normal file
@@ -0,0 +1,98 @@
|
||||
import falcon
|
||||
import mimetypes
|
||||
import os
|
||||
import click
|
||||
from time import sleep
|
||||
from certidude import authority
|
||||
from certidude.auth import login_required, authorize_admin
|
||||
from certidude.decorators import serialize, event_source
|
||||
from certidude.wrappers import Request, Certificate
|
||||
from certidude import config
|
||||
|
||||
class CertificateStatusResource(object):
|
||||
"""
|
||||
openssl ocsp -issuer CAcert_class1.pem -serial 0x<serial no in hex> -url http://localhost -CAfile cacert_both.pem
|
||||
"""
|
||||
def on_post(self, req, resp):
|
||||
ocsp_request = req.stream.read(req.content_length)
|
||||
for component in decoder.decode(ocsp_request):
|
||||
click.echo(component)
|
||||
resp.append_header("Content-Type", "application/ocsp-response")
|
||||
resp.status = falcon.HTTP_200
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
class CertificateAuthorityResource(object):
|
||||
def on_get(self, req, resp):
|
||||
resp.stream = open(config.AUTHORITY_CERTIFICATE_PATH, "rb")
|
||||
resp.append_header("Content-Disposition", "attachment; filename=ca.crt")
|
||||
|
||||
|
||||
class SessionResource(object):
|
||||
@serialize
|
||||
@login_required
|
||||
@authorize_admin
|
||||
@event_source
|
||||
def on_get(self, req, resp):
|
||||
return dict(
|
||||
username=req.context.get("user")[0],
|
||||
event_channel = config.PUSH_EVENT_SOURCE % config.PUSH_TOKEN,
|
||||
autosign_subnets = config.AUTOSIGN_SUBNETS,
|
||||
request_subnets = config.REQUEST_SUBNETS,
|
||||
admin_subnets=config.ADMIN_SUBNETS,
|
||||
admin_users=config.ADMIN_USERS,
|
||||
requests=authority.list_requests(),
|
||||
signed=authority.list_signed(),
|
||||
revoked=authority.list_revoked())
|
||||
|
||||
|
||||
class StaticResource(object):
|
||||
def __init__(self, root):
|
||||
self.root = os.path.realpath(root)
|
||||
|
||||
def __call__(self, req, resp):
|
||||
|
||||
path = os.path.realpath(os.path.join(self.root, req.path[1:]))
|
||||
if not path.startswith(self.root):
|
||||
raise falcon.HTTPForbidden
|
||||
|
||||
if os.path.isdir(path):
|
||||
path = os.path.join(path, "index.html")
|
||||
print("Serving:", path)
|
||||
|
||||
if os.path.exists(path):
|
||||
content_type, content_encoding = mimetypes.guess_type(path)
|
||||
if content_type:
|
||||
resp.append_header("Content-Type", content_type)
|
||||
if content_encoding:
|
||||
resp.append_header("Content-Encoding", content_encoding)
|
||||
resp.stream = open(path, "rb")
|
||||
else:
|
||||
resp.status = falcon.HTTP_404
|
||||
resp.body = "File '%s' not found" % req.path
|
||||
|
||||
|
||||
def certidude_app():
|
||||
from .revoked import RevocationListResource
|
||||
from .signed import SignedCertificateListResource, SignedCertificateDetailResource
|
||||
from .request import RequestListResource, RequestDetailResource
|
||||
from .lease import LeaseResource
|
||||
from .whois import WhoisResource
|
||||
|
||||
app = falcon.API()
|
||||
|
||||
# Certificate authority API calls
|
||||
app.add_route("/api/ocsp/", CertificateStatusResource())
|
||||
app.add_route("/api/certificate/", CertificateAuthorityResource())
|
||||
app.add_route("/api/revoked/", RevocationListResource())
|
||||
app.add_route("/api/signed/{cn}/", SignedCertificateDetailResource())
|
||||
app.add_route("/api/signed/", SignedCertificateListResource())
|
||||
app.add_route("/api/request/{cn}/", RequestDetailResource())
|
||||
app.add_route("/api/request/", RequestListResource())
|
||||
app.add_route("/api/", SessionResource())
|
||||
|
||||
# Gateway API calls, should this be moved to separate project?
|
||||
app.add_route("/api/lease/", LeaseResource())
|
||||
app.add_route("/api/whois/", WhoisResource())
|
||||
|
||||
return app
|
||||
65
certidude/api/lease.py
Normal file
65
certidude/api/lease.py
Normal file
@@ -0,0 +1,65 @@
|
||||
|
||||
from datetime import datetime
|
||||
from pyasn1.codec.der import decoder
|
||||
from certidude import config
|
||||
from certidude.auth import login_required, authorize_admin
|
||||
from certidude.decorators import serialize
|
||||
|
||||
OIDS = {
|
||||
(2, 5, 4, 3) : 'CN', # common name
|
||||
(2, 5, 4, 6) : 'C', # country
|
||||
(2, 5, 4, 7) : 'L', # locality
|
||||
(2, 5, 4, 8) : 'ST', # stateOrProvince
|
||||
(2, 5, 4, 10) : 'O', # organization
|
||||
(2, 5, 4, 11) : 'OU', # organizationalUnit
|
||||
}
|
||||
|
||||
def parse_dn(data):
|
||||
chunks, remainder = decoder.decode(data)
|
||||
dn = ""
|
||||
if remainder:
|
||||
raise ValueError()
|
||||
# TODO: Check for duplicate entries?
|
||||
def generate():
|
||||
for chunk in chunks:
|
||||
for chunkette in chunk:
|
||||
key, value = chunkette
|
||||
yield str(OIDS[key] + "=" + value)
|
||||
return ", ".join(generate())
|
||||
|
||||
|
||||
class LeaseResource(object):
|
||||
@serialize
|
||||
@login_required
|
||||
@authorize_admin
|
||||
def on_get(self, req, resp):
|
||||
from ipaddress import ip_address
|
||||
|
||||
# BUGBUG
|
||||
SQL_LEASES = """
|
||||
SELECT
|
||||
acquired,
|
||||
released,
|
||||
address,
|
||||
identities.data as identity
|
||||
FROM
|
||||
addresses
|
||||
RIGHT JOIN
|
||||
identities
|
||||
ON
|
||||
identities.id = addresses.identity
|
||||
WHERE
|
||||
addresses.released <> 1
|
||||
"""
|
||||
cnx = config.DATABASE_POOL.get_connection()
|
||||
cursor = cnx.cursor()
|
||||
cursor.execute(SQL_LEASES)
|
||||
|
||||
for acquired, released, address, identity in cursor:
|
||||
yield {
|
||||
"acquired": datetime.utcfromtimestamp(acquired),
|
||||
"released": datetime.utcfromtimestamp(released) if released else None,
|
||||
"address": ip_address(bytes(address)),
|
||||
"identity": parse_dn(bytes(identity))
|
||||
}
|
||||
|
||||
119
certidude/api/request.py
Normal file
119
certidude/api/request.py
Normal file
@@ -0,0 +1,119 @@
|
||||
|
||||
import click
|
||||
import falcon
|
||||
import ipaddress
|
||||
import os
|
||||
from certidude import config, authority, helpers, push
|
||||
from certidude.auth import login_required, authorize_admin
|
||||
from certidude.decorators import serialize
|
||||
from certidude.wrappers import Request, Certificate
|
||||
|
||||
class RequestListResource(object):
|
||||
@serialize
|
||||
@authorize_admin
|
||||
def on_get(self, req, resp):
|
||||
return helpers.list_requests()
|
||||
|
||||
def on_post(self, req, resp):
|
||||
"""
|
||||
Submit certificate signing request (CSR) in PEM format
|
||||
"""
|
||||
# Parse remote IPv4/IPv6 address
|
||||
remote_addr = ipaddress.ip_network(req.env["REMOTE_ADDR"])
|
||||
|
||||
# Check for CSR submission whitelist
|
||||
if config.REQUEST_SUBNETS:
|
||||
for subnet in config.REQUEST_SUBNETS:
|
||||
if subnet.overlaps(remote_addr):
|
||||
break
|
||||
else:
|
||||
raise falcon.HTTPForbidden("Forbidden", "IP address %s not whitelisted" % remote_addr)
|
||||
|
||||
if req.get_header("Content-Type") != "application/pkcs10":
|
||||
raise falcon.HTTPUnsupportedMediaType(
|
||||
"This API call accepts only application/pkcs10 content type")
|
||||
|
||||
body = req.stream.read(req.content_length)
|
||||
csr = Request(body)
|
||||
|
||||
# Check if this request has been already signed and return corresponding certificte if it has been signed
|
||||
try:
|
||||
cert = authority.get_signed(csr.common_name)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
else:
|
||||
if cert.pubkey == csr.pubkey:
|
||||
resp.status = falcon.HTTP_SEE_OTHER
|
||||
resp.location = os.path.join(os.path.dirname(req.relative_uri), "signed", csr.common_name)
|
||||
return
|
||||
|
||||
# TODO: check for revoked certificates and return HTTP 410 Gone
|
||||
|
||||
# Process automatic signing if the IP address is whitelisted and autosigning was requested
|
||||
if req.get_param_as_bool("autosign"):
|
||||
for subnet in config.AUTOSIGN_SUBNETS:
|
||||
if subnet.overlaps(remote_addr):
|
||||
try:
|
||||
resp.set_header("Content-Type", "application/x-x509-user-cert")
|
||||
resp.body = authority.sign(csr).dump()
|
||||
return
|
||||
except FileExistsError: # Certificate already exists, try to save the request
|
||||
pass
|
||||
break
|
||||
|
||||
# Attempt to save the request otherwise
|
||||
try:
|
||||
csr = authority.store_request(body)
|
||||
except FileExistsError:
|
||||
raise falcon.HTTPConflict(
|
||||
"CSR with such CN already exists",
|
||||
"Will not overwrite existing certificate signing request, explicitly delete CSR and try again")
|
||||
push.publish("request_submitted", csr.common_name)
|
||||
|
||||
# Wait the certificate to be signed if waiting is requested
|
||||
if req.get_param("wait"):
|
||||
# Redirect to nginx pub/sub
|
||||
url = config.PUSH_LONG_POLL % csr.fingerprint()
|
||||
click.echo("Redirecting to: %s" % url)
|
||||
resp.status = falcon.HTTP_SEE_OTHER
|
||||
resp.set_header("Location", url)
|
||||
else:
|
||||
# Request was accepted, but not processed
|
||||
resp.status = falcon.HTTP_202
|
||||
|
||||
|
||||
class RequestDetailResource(object):
|
||||
@serialize
|
||||
def on_get(self, req, resp, cn):
|
||||
"""
|
||||
Fetch certificate signing request as PEM
|
||||
"""
|
||||
csr = authority.get_request(cn)
|
||||
# if not os.path.exists(path):
|
||||
# raise falcon.HTTPNotFound()
|
||||
|
||||
resp.set_header("Content-Type", "application/pkcs10")
|
||||
resp.set_header("Content-Disposition", "attachment; filename=%s.csr" % csr.common_name)
|
||||
return csr
|
||||
|
||||
@login_required
|
||||
@authorize_admin
|
||||
def on_patch(self, req, resp, cn):
|
||||
"""
|
||||
Sign a certificate signing request
|
||||
"""
|
||||
csr = authority.get_request(cn)
|
||||
cert = authority.sign(csr, overwrite=True, delete=True)
|
||||
os.unlink(csr.path)
|
||||
resp.body = "Certificate successfully signed"
|
||||
resp.status = falcon.HTTP_201
|
||||
resp.location = os.path.join(req.relative_uri, "..", "..", "signed", cn)
|
||||
|
||||
@login_required
|
||||
@authorize_admin
|
||||
def on_delete(self, req, resp, cn):
|
||||
try:
|
||||
authority.delete_request(cn)
|
||||
except FileNotFoundError:
|
||||
resp.body = "No certificate CN=%s found" % cn
|
||||
raise falcon.HTTPNotFound()
|
||||
9
certidude/api/revoked.py
Normal file
9
certidude/api/revoked.py
Normal file
@@ -0,0 +1,9 @@
|
||||
|
||||
from certidude.authority import export_crl
|
||||
|
||||
class RevocationListResource(object):
|
||||
def on_get(self, req, resp):
|
||||
resp.set_header("Content-Type", "application/x-pkcs7-crl")
|
||||
resp.append_header("Content-Disposition", "attachment; filename=ca.crl")
|
||||
resp.body = export_crl()
|
||||
|
||||
38
certidude/api/signed.py
Normal file
38
certidude/api/signed.py
Normal file
@@ -0,0 +1,38 @@
|
||||
|
||||
import falcon
|
||||
from certidude import authority
|
||||
from certidude.auth import login_required, authorize_admin
|
||||
from certidude.decorators import serialize
|
||||
|
||||
class SignedCertificateListResource(object):
|
||||
@serialize
|
||||
@authorize_admin
|
||||
def on_get(self, req, resp):
|
||||
for j in authority.list_signed():
|
||||
yield omit(
|
||||
key_type=j.key_type,
|
||||
key_length=j.key_length,
|
||||
identity=j.identity,
|
||||
cn=j.common_name,
|
||||
c=j.country_code,
|
||||
st=j.state_or_county,
|
||||
l=j.city,
|
||||
o=j.organization,
|
||||
ou=j.organizational_unit,
|
||||
fingerprint=j.fingerprint())
|
||||
|
||||
|
||||
class SignedCertificateDetailResource(object):
|
||||
@serialize
|
||||
def on_get(self, req, resp, cn):
|
||||
try:
|
||||
return authority.get_signed(cn)
|
||||
except FileNotFoundError:
|
||||
resp.body = "No certificate CN=%s found" % cn
|
||||
raise falcon.HTTPNotFound()
|
||||
|
||||
@login_required
|
||||
@authorize_admin
|
||||
def on_delete(self, req, resp, cn):
|
||||
authority.revoke_certificate(cn)
|
||||
|
||||
52
certidude/api/whois.py
Normal file
52
certidude/api/whois.py
Normal file
@@ -0,0 +1,52 @@
|
||||
|
||||
import falcon
|
||||
import ipaddress
|
||||
from certidude import config
|
||||
from certidude.decorators import serialize
|
||||
|
||||
def address_to_identity(cnx, addr):
|
||||
"""
|
||||
Translate currently online client's IP-address to distinguished name
|
||||
"""
|
||||
|
||||
SQL_LEASES = """
|
||||
SELECT
|
||||
acquired,
|
||||
released,
|
||||
identities.data as identity
|
||||
FROM
|
||||
addresses
|
||||
RIGHT JOIN
|
||||
identities
|
||||
ON
|
||||
identities.id = addresses.identity
|
||||
WHERE
|
||||
address = %s AND
|
||||
released IS NOT NULL
|
||||
"""
|
||||
|
||||
cursor = cnx.cursor()
|
||||
import struct
|
||||
cursor.execute(SQL_LEASES, (struct.pack("!L", int(addr)),))
|
||||
|
||||
for acquired, released, identity in cursor:
|
||||
return {
|
||||
"acquired": datetime.utcfromtimestamp(acquired),
|
||||
"identity": parse_dn(bytes(identity))
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
class WhoisResource(object):
|
||||
@serialize
|
||||
def on_get(self, req, resp):
|
||||
identity = address_to_identity(
|
||||
config.DATABASE_POOL.get_connection(),
|
||||
ipaddress.ip_address(req.get_param("address") or req.env["REMOTE_ADDR"])
|
||||
)
|
||||
|
||||
if identity:
|
||||
return identity
|
||||
else:
|
||||
resp.status = falcon.HTTP_403
|
||||
resp.body = "Failed to look up node %s" % req.env["REMOTE_ADDR"]
|
||||
Reference in New Issue
Block a user