Skip to content

Commit

Permalink
Implement VaultPkiManager
Browse files Browse the repository at this point in the history
  • Loading branch information
hartmans committed Nov 22, 2024
1 parent b88055f commit 6ffe05a
Show file tree
Hide file tree
Showing 4 changed files with 176 additions and 8 deletions.
18 changes: 13 additions & 5 deletions carthage/pki.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,14 +80,22 @@ class PkiManager(SetupTaskMixin, AsyncInjectable):
'''

async def issue_credentials(self, hostname: str, tag: str) -> list[str, str]:
'''
Issue a set of credentials for a given host.
'''Issue a set of credentials for a given host.
:param host: the hostname to use as the CN and in a DNS SAN.
:param tag: A tag that describes the context in which a given credential is requested. For example this could include the name of the setup_task and name of the :class:`carthage.Machine` that credentials are being installed on. It is an error to request credentials for the same hostname and for different tags within the same invocation of Carthage. PkiManagers should return different keys for situations when different tags are used across Carthage runs. (Return different keys all the time is even better.
:param tag: A tag that describes the context in which a given
credential is requested. For example this could include the
name of the setup_task and name of the
:class:`carthage.Machine` that credentials are being installed
on. It is an error to request credentials for the same
hostname and for different tags within the same invocation of
Carthage. PkiManagers should return different keys for
situations when different tags are used across Carthage
runs. (Return different keys all the time is even better.
:returns: key, certificate
'''
raise NotImplementedError

Expand Down Expand Up @@ -180,9 +188,9 @@ class TrustStore(AsyncInjectable):
'''

#: a name of this trust store. Trust stores with a different set of trusted certificates must have different names.

_ca_file = None
name: str
_ca_file = None

async def trusted_certificates(self):
'''
An asynchronous iterator yielding pairs of anchor_name, certificate. The anchor_name is used by interfaces like ``ca-certificates`` that need to name each trust root. If the underlying store does not have anchor names, hashes can be used.
Expand Down
16 changes: 13 additions & 3 deletions carthage/vault/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,14 @@
from carthage.ssh import AuthorizedKeysFile, SshAgent, SshKey
from carthage.utils import memoproperty

__all__ = []


class VaultError(RuntimeError):
pass

__all__ += ['VaultError']


class VaultConfig(ConfigSchema, prefix="vault"):

Expand All @@ -44,11 +48,11 @@ class VaultConfig(ConfigSchema, prefix="vault"):
)
class Vault(Injectable):

def __init__(self, injector, token=None):
def __init__(self, token=None, **kwargs):
super().__init__(**kwargs)
injector = self.injector
config = injector(ConfigLayout)
self.vault_config = config.vault
self.injector = injector
super().__init__()
#: The hvac client for this vault
self.client = None
self.setup_client(token)
Expand Down Expand Up @@ -108,6 +112,8 @@ def apply_config(self, config):
_apply_config_to_vault(self.client, config)
return

__all__ += ['Vault']


def _apply_config_to_vault(client, config):
config = dict(config) # copy so we can mutate
Expand Down Expand Up @@ -287,6 +293,10 @@ def pubkey_contents(self):
return self._pubs


from .pki import VaultPkiManager

__all__ += ['VaultPkiManager']

@inject(injector=Injector)
def carthage_plugin(injector):
injector.add_provider(Vault)
Expand Down
70 changes: 70 additions & 0 deletions carthage/vault/pki.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# Copyright (C) 2024, Hadron Industries, Inc.
# Carthage is free software; you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License version 3
# as published by the Free Software Foundation. It is distributed
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the file
# LICENSE for details.

import asyncio
from carthage import *
import carthage.pki
from . import Vault

__all__ = []

async def run_in_executor(func, *args):
return await asyncio.get_event_loop().run_in_executor(None, func, *args)

@inject_autokwargs(
vault=Vault)
class VaultPkiManager(carthage.pki.PkiManager):

'''
A PKI manager mounted corresponding to a vault pki secrets engine.
'''
path:str = 'pki' #: Path at which secrets engine is mounted
role:str = None #:Role to issue against.

def __init__(self, *, path=None, role=None, **kwargs):
if path is not None:
self.path = Path
if role is None and self.role is None:
raise TypeError('role must be set in the constructor or subclass')
if role:
self.role = role
super().__init__(**kwargs)

async def issue_credentials(self, hostname:str, tag:str):
def cb():
return self.vault.client.write(
f'{self.path}/issue/{self.role}',
common_name=hostname
)
result = await run_in_executor(cb)
key = result['data']['private_key']
cert = result['data']['certificate']
return key, cert

async def trust_store(self):
def cb():
res = self.vault.client.read(f'{self.path}/ca/pem')
return res.text
ca_pem = await run_in_executor(cb)
return await self.ainjector(
carthage.pki.SimpleTrustStore,
'vault_'+self.path,
dict(ca=ca_pem))

async def certificates(self):
def cb_list():
res = self.vault.client.list(f'{self.path}/certs')
return res['data']['keys']
def cb_cert(key):
res = self.vault.client.read(f'{self.path}/cert/{key}')
return res['data']['certificate']

for key in await run_in_executor(cb_list):
yield await run_in_executor(cb_cert, key)

__all__ += ['VaultPkiManager']
80 changes: 80 additions & 0 deletions tests/test_vault.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# Copyright (C) 2024, Hadron Industries, Inc.
# Carthage is free software; you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License version 3
# as published by the Free Software Foundation. It is distributed
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the file
# LICENSE for details.

import asyncio
import pytest
import sys
import time
from carthage.pytest import *
from carthage import *
from carthage.vault import *




@pytest.fixture(scope='module')
def vault():
'''
Start a vault in dev mode
'''
try:
from sh import vault as vault_cmd
except Exception:
pytest.skip('vault not installed')
vault_proc = vault_cmd('server', '-dev', _bg=True, _bg_exc=False, _out=sys.stdout,
_err_to_out=True)
time.sleep(1)
yield
vault_proc.kill()

@pytest.fixture()
def ainjector(ainjector, vault):
ainjector.add_provider(ConfigLayout)
cl = ainjector.get_instance(ConfigLayout)
cl.vault.address = 'http://127.0.0.1:8200/'
ainjector.add_provider(Vault)
vault = ainjector.get_instance(Vault)
vault.apply_config(
{
'secrets':dict(
pki={'type':'pki'}),
'pki/root/generate/internal': dict(
ttl='120h',
common_name='ROOT'),
'pki/roles/role': dict(
allow_any_name=True,
ttl='30h'
),
})
class pki(VaultPkiManager):
role = 'role'
ainjector.add_provider(pki)
yield ainjector

@async_test
async def test_pki_issue(ainjector):
pki = await ainjector.get_instance_async(VaultPkiManager)
await pki.issue_credentials('evil.com', 'tag')


@async_test
async def test_pki_certs(ainjector):
pki = await ainjector.get_instance_async(VaultPkiManager)
key_1, cert_1 = await pki.issue_credentials('internet.com', 'tag')
key_2,cert_2 = await pki.issue_credentials('dns.net', 'tag')
certs = [c async for c in pki.certificates()]
assert cert_1 in certs
assert cert_2 in certs

@async_test
async def test_trust_store(ainjector):
pki = await ainjector.get_instance_async(VaultPkiManager)
trust_store = await pki.trust_store()
certs = [c async for c in trust_store.trusted_certificates()]
await trust_store.ca_file()

0 comments on commit 6ffe05a

Please sign in to comment.