Skip to content

Commit

Permalink
Merge pull request #244 from nolar/242-ssl-data-fields
Browse files Browse the repository at this point in the history
Support raw data for  SSL certs, pkeys, and CA
  • Loading branch information
nolar authored Nov 20, 2019
2 parents e68e7cb + 84e60d6 commit 977553b
Show file tree
Hide file tree
Showing 3 changed files with 377 additions and 9 deletions.
90 changes: 81 additions & 9 deletions kopf/clients/auth.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import base64
import functools
import os
import ssl
import tempfile
import warnings
from contextvars import ContextVar
from typing import Optional, Callable, Any, TypeVar, Dict, cast
from typing import Optional, Callable, Any, TypeVar, Dict, Iterator, Mapping, cast

import aiohttp

Expand Down Expand Up @@ -83,29 +86,63 @@ class APISession(aiohttp.ClientSession):
"""
server: str
default_namespace: Optional[str] = None
_tempfiles: "_TempFiles"

@classmethod
def from_connection_info(
cls,
info: credentials.ConnectionInfo,
) -> "APISession":

# Some SSL data are not accepted directly, so we have to use temp files.
tempfiles = _TempFiles()
ca_path: Optional[str]
certificate_path: Optional[str]
private_key_path: Optional[str]

if info.ca_path and info.ca_data:
raise credentials.LoginError("Both CA path & data are set. Need only one.")
elif info.ca_path:
ca_path = info.ca_path
elif info.ca_data:
ca_path = tempfiles[base64.b64decode(info.ca_data)]
else:
ca_path = None

if info.certificate_path and info.certificate_data:
raise credentials.LoginError("Both certificate path & data are set. Need only one.")
elif info.certificate_path:
certificate_path = info.certificate_path
elif info.certificate_data:
certificate_path = tempfiles[base64.b64decode(info.certificate_data)]
else:
certificate_path = None

if info.private_key_path and info.private_key_data:
raise credentials.LoginError("Both private key path & data are set. Need only one.")
elif info.private_key_path:
private_key_path = info.private_key_path
elif info.private_key_data:
private_key_path = tempfiles[base64.b64decode(info.private_key_data)]
else:
private_key_path = None

# The SSL part (both client certificate auth and CA verification).
# TODO:2: also use cert/pkey/ca binary data
context: ssl.SSLContext
if info.certificate_path and info.private_key_path:
if certificate_path and private_key_path:
context = ssl.create_default_context(
cafile=info.ca_path,
purpose=ssl.Purpose.CLIENT_AUTH)
purpose=ssl.Purpose.CLIENT_AUTH,
cafile=ca_path)
context.load_cert_chain(
certfile=info.certificate_path,
keyfile=info.private_key_path)
certfile=certificate_path,
keyfile=private_key_path)
else:
context = ssl.create_default_context(
cafile=info.ca_path)
cafile=ca_path)

if info.insecure:
context.verify_mode = ssl.CERT_NONE
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE

# The token auth part.
headers: Dict[str, str] = {}
Expand Down Expand Up @@ -139,10 +176,45 @@ def from_connection_info(
# Add the extra payload information. We avoid overriding the constructor.
session.server = info.server
session.default_namespace = info.default_namespace
session._tempfiles = tempfiles # for purging on garbage collection

return session


class _TempFiles(Mapping[bytes, str]):
"""
A container for the temporary files, which are purged on garbage collection.
The files are purged when the container is garbage-collected. The container
is garbage-collected when its parent `APISession` is garbage-collected.
"""

def __init__(self) -> None:
super().__init__()
self._paths: Dict[bytes, str] = {}

def __len__(self) -> int:
return len(self._paths)

def __iter__(self) -> Iterator[bytes]:
return iter(self._paths)

def __getitem__(self, item: bytes) -> str:
if item not in self._paths:
with tempfile.NamedTemporaryFile(delete=False) as f:
f.write(item)
self._paths[item] = f.name
return self._paths[item]

def __del__(self) -> None:
for _, path in self._paths.items():
try:
os.remove(path)
except OSError:
pass
self._paths.clear()


# DEPRECATED: Should be removed with login()/get_pykube_cfg()/get_pykube_api().
# Previously, in some cases, get_pykube_cfg() was monkey-patched to inject
# custom authentication methods. Support these hacks as long as possible.
Expand Down
255 changes: 255 additions & 0 deletions tests/authentication/test_credentials.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
import base64
import ssl
import textwrap

import pytest

from kopf.clients.auth import reauthenticated_request, vault_var
from kopf.structs.credentials import Vault, ConnectionInfo

# These are Minikube's locally geenrated certificates (CN=minikubeCA).
# They are not in any public use, and are regenerated regularly.
SAMPLE_MINIKUBE_CA = textwrap.dedent('''
-----BEGIN CERTIFICATE-----
MIIC5zCCAc+gAwIBAgIBATANBgkqhkiG9w0BAQsFADAVMRMwEQYDVQQDEwptaW5p
a3ViZUNBMB4XDTE5MDUyMTA5MTgzNloXDTI5MDUxOTA5MTgzNlowFTETMBEGA1UE
AxMKbWluaWt1YmVDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANNU
9eyjlDfWZNSTXbdM9uebYseWWo6KeGGQ/OISrnVc6+AjuIv/fxtdCc8nVyLXBWu2
dlDjBqOsG2WfY7m1RVwsF0L2G8pgNZJ4eOww/PyDZzzIcB911eWiry528YB2PZQu
sN6sUItSZrHsin3dkcEZMUKcvVOY3FaNqXukCoMywZBO7QlLZasHhCCaanMFjxBx
WiqB4gxcyTlGRBSoa49agSW2r45873xmJ+JglI/tNjeobGLynYwrDvRWmrhVOFAj
QeiMr5lkzVO5cC2t84WdEihXVqFcZQUe0jfRHmmgpUxJRtiJMv7yudgnK2ALdhOY
eDVtV1wIyWgLpF2lZk8CAwEAAaNCMEAwDgYDVR0PAQH/BAQDAgKkMB0GA1UdJQQW
MBQGCCsGAQUFBwMCBggrBgEFBQcDATAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3
DQEBCwUAA4IBAQBdsqvuvK+8RJ5xqwGkdpSAK1U2LrZ3Hm0MzXoEo8GH79F1yubv
Ig1VRLHDIDY1d/fKrK4a7uulSFTFvpt6AGSB/225wJVBQUAALH1lPkTXq5TDi5jE
NqoXk3d61qf9StUEc1YehL6ZgkSknNU7ksAe5Ht0lfJlSa3DmACkI4CZJ1F5cztk
m2p3RZYkxizY2i/9P34f59F3XCNUSOW52aJgLhnMugEM0baOTHN0mcYZRGmrunT6
fs/5eZq6ZrXBu0nIkEZkEWAM2WoqDGxMlUao5IOnf289HyBxJTFGte5tysg1sJF0
JCH4VcilJllzUki594R1Yv8O5qtxkXXfXQNR
-----END CERTIFICATE-----
''').strip()

SAMPLE_MINIKUBE_CERT = textwrap.dedent('''
-----BEGIN CERTIFICATE-----
MIIDADCCAeigAwIBAgIBAjANBgkqhkiG9w0BAQsFADAVMRMwEQYDVQQDEwptaW5p
a3ViZUNBMB4XDTE5MTExMzA5NDMzNFoXDTIwMTExMzA5NDMzNFowMTEXMBUGA1UE
ChMOc3lzdGVtOm1hc3RlcnMxFjAUBgNVBAMTDW1pbmlrdWJlLXVzZXIwggEiMA0G
CSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCut0LegwHP4kJp9Uf89vjuslIMi6hv
BiBNhSr8wCZ9uuFUN2dvBnCPXX/xvxYxpBKh0WUKX7sYfKdaMjxVr1ndnwu63e5A
CW7919uRH3fhVV7rTntlO+rUeyHXNlSQue8oVlvO+8D+Qzlna02axt/5PwsPGD/G
lw7Ti0f/LjmmqTB0T6yCsyOH90d7pQ0yuiOyDK072Ns1vTf6hkrkiNQaRhUjEtqm
rMq/A4xpb7h+z6LWlRyv6/DBJsLmpDS99hqZbbj1U6IJ56r0JpQuq7CJxeE/F2t5
I1i0k8TJ4CQRrHvXovl6wI+zmOTmKMy5uDdKUEkfe6vck+x16gV2RK79AgMBAAGj
PzA9MA4GA1UdDwEB/wQEAwIFoDAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUH
AwIwDAYDVR0TAQH/BAIwADANBgkqhkiG9w0BAQsFAAOCAQEAbo/PxDz7K9dCAcxi
S0RdZvOu2RYHqRmc/vN1B5JDHLcYcCet4iuJ44BuWZc/Oe1sXdy6DOZO2UQHAF0F
5cAaBnkqpz/yvUzkE3NvdXpU/9leo3O6XeKKi0Fi+hv09nhh/tJgh/XDxWfAAkeG
I3AQCkcHrqFrpBMxuWUXlnexwsvvbdEVkpVMVwSRfpsxLfxV62HCU90EU0823UKW
V1npcRXBtxK/jqWZbLd5buRul+V6PKa4KRY22d8it+9MDAMgPFQosG1ShhQropul
VJUvAQ14dPpiyQN4FRI3MljVykFe2cWI0rVwoboy9TEniaMPqr3MqujOlUv7KTpk
lKRP5w==
-----END CERTIFICATE-----
''').strip()

SAMPLE_MINIKUBE_PKEY = textwrap.dedent('''
-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEArrdC3oMBz+JCafVH/Pb47rJSDIuobwYgTYUq/MAmfbrhVDdn
bwZwj11/8b8WMaQSodFlCl+7GHynWjI8Va9Z3Z8Lut3uQAlu/dfbkR934VVe6057
ZTvq1Hsh1zZUkLnvKFZbzvvA/kM5Z2tNmsbf+T8LDxg/xpcO04tH/y45pqkwdE+s
grMjh/dHe6UNMrojsgytO9jbNb03+oZK5IjUGkYVIxLapqzKvwOMaW+4fs+i1pUc
r+vwwSbC5qQ0vfYamW249VOiCeeq9CaULquwicXhPxdreSNYtJPEyeAkEax716L5
esCPs5jk5ijMubg3SlBJH3ur3JPsdeoFdkSu/QIDAQABAoIBAAk8FoS8V/Qs+WWw
WUW9qBq1wjB3kUeNA1gVmdgSL/alUhOpegYcSQbK4mBcwUeObI2xC64osTLyI8ZY
sWe2BQH5zhzqbhVkakFwj2J0T1nRsVquo0cOi7L/byJ49K9RpJp1NhUSqXjHBNm6
ijeMG3qJIoSBu507jsUPr5aFUvbEFCby+VvU/UljS1dK+5wm7QcGgRrcXc8ZCIuk
1P5YX6Tr9RYzYNjc/zB8czIyoISSSTk9uroYzvuMCgYTQ4WzWvX8UwchdfZyV7Gq
kdKjG6IkGxvlS0Lc8534LRlH8iJR+wPAlYKHBDa0Rc3qZFsvgjnbDCCa6YqLoNNn
ltsz7wECgYEA5IrnX8EHXyeaYnyiVI2xk6QTkmLrtovd1Ue2GwQ9BJgtaDrT0xil
UV5NV4VUu5Zid7cqmIyFyh+7jjIex+dpfXT94+wr1HXxNYbLHeCnCZWcomxaD8pU
Bh8B8wSRgjOFW33q6APDkFJPO92O96B+BczSgOFmEvuk8Kj7aYFahWUCgYEAw7Ta
YiD66I+eK8B2+lWfPNfpIddW7D3Dn9cSW2RVazMyinTsVBm3p63kpCQeVaaX5key
WiyY6phTvIfJ45pTzrS/kpA2zGcB1FFnB1xvM0bzpbIxTOQBGQUH1mSQmNT6+0VZ
+GdILRKedp4qg7BLh7VElSCYGVy1Yr62Rp01FbkCgYB3QZxWtQ05tBq1hb/XS1D8
b8PewUuqp/WL063NDzsf6KDZIMlkABpUCVdmciay9FhRi/zoOXue60wdeT3ipni/
hIrvok+EwD6r5bib0JyZPb7MaqncT4Hk581GmH2taWEPSveHNl+YMbsyy/xMby0T
rbuykOuIwFNjWWpHtb4cmQKBgGF4MUuuIUiyPpSLxrXm7ufeoL26AhCmskdpVjsu
PVymowVSNmGsbUuVz8nwMyt1TTHjg3BlxcMRGqNK/cHdmt/YJZFZQfGLW93irO19
m+Rt8esUVHl3FRTg7IZaj6mOaXG7mJOe3NOV8lYhcAsmQnfUT9P158q54ZzMXvvM
UCQBAoGBAMTbVdbybvzqOSIKGeqIVlX7L2Fp55lYC8MfTwQzjPeJMQ8YdeNquv+M
hweBrRg1DXxdaicfpCuqITU1WkqHd/NGNQX2h8VleiR6t22dZb8nBplO1l1e3XgY
lUXVsCYgw8yNCm10xGCelpJ4nxxPhf5apbz4F3nGORGfsv5C+x++
-----END RSA PRIVATE KEY-----
''').strip()


@reauthenticated_request
async def fn(session):
return session


@pytest.fixture(autouse=True)
def vault():
vault = Vault()
vault_var.set(vault)
return vault


@pytest.fixture
def cabase64(tmpdir):
return base64.encodebytes(SAMPLE_MINIKUBE_CA.encode('ascii'))


@pytest.fixture
def certbase64(tmpdir):
return base64.encodebytes(SAMPLE_MINIKUBE_CERT.encode('ascii'))


@pytest.fixture
def pkeybase64(tmpdir):
return base64.encodebytes(SAMPLE_MINIKUBE_PKEY.encode('ascii'))


@pytest.fixture
def cafile(tmpdir):
path = tmpdir / 'ca.crt'
path.write_text(SAMPLE_MINIKUBE_CA, encoding='utf-8')
return str(path)


@pytest.fixture
def certfile(tmpdir):
path = tmpdir / 'client.crt'
path.write_text(SAMPLE_MINIKUBE_CERT, encoding='utf-8')
return str(path)


@pytest.fixture
def pkeyfile(tmpdir):
path = tmpdir / 'client.key'
path.write_text(SAMPLE_MINIKUBE_PKEY, encoding='utf-8')
return str(path)


async def test_basic_auth(vault):
await vault.populate({
'id': ConnectionInfo(
server='http://localhost',
username='username',
password='password',
),
})
session = await fn()

assert session._default_auth.login == 'username'
assert session._default_auth.password == 'password'
assert 'Authorization' not in session._default_headers


async def test_header_with_token_only(vault):
await vault.populate({
'id': ConnectionInfo(
server='http://localhost',
token='token',
),
})
session = await fn()

assert session._default_auth is None
assert session._default_headers['Authorization'] == 'Bearer token'


async def test_header_with_schema_only(vault):
await vault.populate({
'id': ConnectionInfo(
server='http://localhost',
scheme='Digest xyz',
),
})
session = await fn()

assert session._default_auth is None
assert session._default_headers['Authorization'] == 'Digest xyz'


async def test_header_with_schema_and_token(vault):
await vault.populate({
'id': ConnectionInfo(
server='http://localhost',
scheme='Digest',
token='xyz',
),
})
session = await fn()

assert session._default_auth is None
assert session._default_headers['Authorization'] == 'Digest xyz'


async def test_ca_insecure(vault, cafile):
await vault.populate({
'id': ConnectionInfo(
server='http://localhost',
insecure=True,
),
})
session = await fn()

ctx = session.connector._ssl
assert ctx.verify_mode == ssl.CERT_NONE


async def test_ca_as_path(vault, cafile):
await vault.populate({
'id': ConnectionInfo(
server='http://localhost',
ca_path=cafile,
),
})
session = await fn()

ctx = session.connector._ssl
assert len(ctx.get_ca_certs()) == 1
assert ctx.cert_store_stats()['x509'] == 1
assert ctx.cert_store_stats()['x509_ca'] == 1


async def test_ca_as_data(vault, cabase64):
await vault.populate({
'id': ConnectionInfo(
server='http://localhost',
ca_data=cabase64,
),
})
session = await fn()

ctx = session.connector._ssl
assert len(ctx.get_ca_certs()) == 1
assert ctx.cert_store_stats()['x509'] == 1
assert ctx.cert_store_stats()['x509_ca'] == 1


# TODO: find a way to test that the client certificates/pkeys are indeed loaded.
# TODO: currently, we only test that the parsing/loading does not fail at all.
async def test_clientcert_as_path(vault, cafile, certfile, pkeyfile):
await vault.populate({
'id': ConnectionInfo(
server='http://localhost',
ca_path=cafile,
certificate_path=certfile,
private_key_path=pkeyfile,
),
})
await fn()


async def test_clientcert_as_data(vault, cafile, certbase64, pkeybase64):
await vault.populate({
'id': ConnectionInfo(
server='http://localhost',
ca_path=cafile,
certificate_data=certbase64,
private_key_data=pkeybase64,
),
})
await fn()
Loading

0 comments on commit 977553b

Please sign in to comment.