mirror of
https://salsa.debian.org/freeipa-team/freeipa.git
synced 2025-02-25 18:55:28 -06:00
Add ipa-custodia service
Add a customized Custodia daemon and enable it after installation. Generates server keys and loads them in LDAP autonomously on install or update. Provides client code classes too. Signed-off-by: Simo Sorce <simo@redhat.com> Reviewed-By: Jan Cholasta <jcholast@redhat.com>
This commit is contained in:
parent
ba22999cef
commit
463dda3067
@ -97,6 +97,8 @@ BuildRequires: python-pytest-multihost >= 0.5
|
||||
BuildRequires: python-pytest-sourceorder
|
||||
BuildRequires: python-kdcproxy >= 0.3
|
||||
BuildRequires: python-six
|
||||
BuildRequires: python-jwcrypto
|
||||
BuildRequires: custodia
|
||||
|
||||
%description
|
||||
IPA is an integrated solution to provide centrally managed Identity (users,
|
||||
@ -158,6 +160,8 @@ Requires: p11-kit
|
||||
Requires: systemd-python
|
||||
Requires: %{etc_systemd_dir}
|
||||
Requires: gzip
|
||||
Requires: python-gssapi >= 1.1.0
|
||||
Requires: custodia
|
||||
|
||||
Conflicts: %{alt_name}-server
|
||||
Obsoletes: %{alt_name}-server < %{version}
|
||||
@ -322,6 +326,7 @@ Requires: wget
|
||||
Requires: dbus-python
|
||||
Requires: python-setuptools
|
||||
Requires: python-six
|
||||
Requires: python-jwcrypto
|
||||
|
||||
Conflicts: %{alt_name}-python
|
||||
Obsoletes: %{alt_name}-python < %{version}
|
||||
@ -512,6 +517,7 @@ mkdir -p %{buildroot}%{etc_systemd_dir}
|
||||
install -m 644 init/systemd/ipa.service %{buildroot}%{_unitdir}/ipa.service
|
||||
install -m 644 init/systemd/ipa_memcached.service %{buildroot}%{_unitdir}/ipa_memcached.service
|
||||
install -m 644 init/systemd/httpd.service %{buildroot}%{etc_systemd_dir}/httpd.service
|
||||
install -m 644 init/systemd/ipa-custodia.service %{buildroot}%{_unitdir}/ipa-custodia.service
|
||||
# END
|
||||
mkdir -p %{buildroot}/%{_localstatedir}/lib/ipa/backup
|
||||
%endif # ONLY_CLIENT
|
||||
@ -536,6 +542,10 @@ mkdir -p %{buildroot}%{_sysconfdir}/cron.d
|
||||
(cd %{buildroot}/%{python_sitelib}/ipatests && find . -type f | \
|
||||
sed -e 's,\.py.*$,.*,g' | sort -u | \
|
||||
sed -e 's,\./,%%{python_sitelib}/ipatests/,g' ) >tests-python.list
|
||||
|
||||
mkdir -p %{buildroot}%{_sysconfdir}/ipa/custodia
|
||||
|
||||
|
||||
%endif # ONLY_CLIENT
|
||||
|
||||
%clean
|
||||
@ -739,6 +749,7 @@ fi
|
||||
%attr(644,root,root) %{_unitdir}/ipa-dnskeysyncd.service
|
||||
%attr(644,root,root) %{_unitdir}/ipa-ods-exporter.socket
|
||||
%attr(644,root,root) %{_unitdir}/ipa-ods-exporter.service
|
||||
%attr(644,root,root) %{_unitdir}/ipa-custodia.service
|
||||
%attr(644,root,root) %{etc_systemd_dir}/httpd.service
|
||||
# END
|
||||
%dir %{python_sitelib}/ipaserver
|
||||
@ -856,6 +867,7 @@ fi
|
||||
%ghost %{_localstatedir}/lib/ipa/pki-ca/publish
|
||||
%ghost %{_localstatedir}/named/dyndb-ldap/ipa
|
||||
%attr(755,root,root) %{_libdir}/krb5/plugins/kdb/ipadb.so
|
||||
%dir %attr(0700,root,root) %{_sysconfdir}/ipa/custodia
|
||||
%{_mandir}/man1/ipa-replica-conncheck.1.gz
|
||||
%{_mandir}/man1/ipa-replica-install.1.gz
|
||||
%{_mandir}/man1/ipa-replica-manage.1.gz
|
||||
@ -938,6 +950,8 @@ fi
|
||||
%{python_sitelib}/ipapython/dnssec/*.py*
|
||||
%dir %{python_sitelib}/ipapython/install
|
||||
%{python_sitelib}/ipapython/install/*.py*
|
||||
%dir %{python_sitelib}/ipapython/secrets
|
||||
%{python_sitelib}/ipapython/secrets/*.py*
|
||||
%dir %{python_sitelib}/ipalib
|
||||
%{python_sitelib}/ipalib/*
|
||||
%dir %{python_sitelib}/ipaplatform
|
||||
|
13
init/systemd/ipa-custodia.service
Normal file
13
init/systemd/ipa-custodia.service
Normal file
@ -0,0 +1,13 @@
|
||||
[Unit]
|
||||
Description=IPA Custodia Service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
|
||||
ExecStart=/usr/sbin/custodia /etc/ipa/custodia/custodia.conf
|
||||
PrivateTmp=yes
|
||||
Restart=on-failure
|
||||
RestartSec=60s
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
@ -1,5 +1,5 @@
|
||||
#
|
||||
# VERSION 18 - DO NOT REMOVE THIS LINE
|
||||
# VERSION 19 - DO NOT REMOVE THIS LINE
|
||||
#
|
||||
# This file may be overwritten on upgrades.
|
||||
#
|
||||
@ -103,6 +103,14 @@ WSGIScriptReloading Off
|
||||
Allow from all
|
||||
</Location>
|
||||
|
||||
# Custodia stuff is redirected to the custodia daemon
|
||||
# after authentication
|
||||
<Location "/ipa/keys/">
|
||||
ProxyPass "unix:/run/httpd/ipa-custodia.sock|http://localhost/keys/"
|
||||
RequestHeader set GSS_NAME %{GSS_NAME}s
|
||||
RequestHeader set REMOTE_USER %{REMOTE_USER}s
|
||||
</Location>
|
||||
|
||||
# This is where we redirect on failed auth
|
||||
Alias /ipa/errors "/usr/share/ipa/html"
|
||||
|
||||
|
@ -28,6 +28,7 @@ app_DATA = \
|
||||
anonymous-vlv.ldif \
|
||||
bootstrap-template.ldif \
|
||||
caJarSigningCert.cfg.template \
|
||||
custodia.conf.template \
|
||||
default-aci.ldif \
|
||||
default-caacl.ldif \
|
||||
default-hbac.ldif \
|
||||
|
@ -167,6 +167,12 @@ objectClass: nsContainer
|
||||
objectClass: top
|
||||
cn: certificates
|
||||
|
||||
dn: cn=custodia,cn=ipa,cn=etc,$SUFFIX
|
||||
changetype: add
|
||||
objectClass: nsContainer
|
||||
objectClass: top
|
||||
cn: custodia
|
||||
|
||||
dn: cn=s4u2proxy,cn=etc,$SUFFIX
|
||||
changetype: add
|
||||
objectClass: nsContainer
|
||||
|
28
install/share/custodia.conf.template
Normal file
28
install/share/custodia.conf.template
Normal file
@ -0,0 +1,28 @@
|
||||
[global]
|
||||
server_version = "IPAKeys/0.0.1"
|
||||
server_socket = $IPA_CUSTODIA_SOCKET
|
||||
auditlog = $IPA_CUSTODIA_AUDIT_LOG
|
||||
|
||||
[auth:simple]
|
||||
handler = custodia.httpd.authenticators.SimpleCredsAuth
|
||||
uid = 48
|
||||
gid = 48
|
||||
|
||||
[auth:header]
|
||||
handler = custodia.httpd.authenticators.SimpleHeaderAuth
|
||||
header = GSS_NAME
|
||||
|
||||
[authz:kemkeys]
|
||||
handler = ipapython.secrets.kem.IPAKEMKeys
|
||||
paths = /keys
|
||||
store = ipa
|
||||
server_keys = $IPA_CUSTODIA_CONF_DIR/server.keys
|
||||
|
||||
[store:ipa]
|
||||
handler = ipapython.secrets.store.iSecStore
|
||||
ldap_uri = $LDAP_URI
|
||||
|
||||
[/keys]
|
||||
handler = custodia.secrets.Secrets
|
||||
allowed_keytypes = kem
|
||||
store = ipa
|
4
install/updates/73-custodia.update
Normal file
4
install/updates/73-custodia.update
Normal file
@ -0,0 +1,4 @@
|
||||
dn: cn=custodia,cn=ipa,cn=etc,$SUFFIX
|
||||
default: objectClass: top
|
||||
default: objectClass: nsContainer
|
||||
default: cn: custodia
|
@ -356,5 +356,9 @@ class BasePathNamespace(object):
|
||||
KDCPROXY_CONFIG = '/etc/ipa/kdcproxy/kdcproxy.conf'
|
||||
CERTMONGER = '/usr/sbin/certmonger'
|
||||
NETWORK_MANAGER_CONFIG_DIR = '/etc/NetworkManager/conf.d'
|
||||
IPA_CUSTODIA_CONF_DIR = '/etc/ipa/custodia'
|
||||
IPA_CUSTODIA_CONF = '/etc/ipa/custodia/custodia.conf'
|
||||
IPA_CUSTODIA_SOCKET = '/run/httpd/ipa-custodia.sock'
|
||||
IPA_CUSTODIA_AUDIT_LOG = '/var/log/ipa-custodia.audit.log'
|
||||
|
||||
path_namespace = BasePathNamespace
|
||||
|
0
ipapython/secrets/__init__.py
Normal file
0
ipapython/secrets/__init__.py
Normal file
99
ipapython/secrets/client.py
Normal file
99
ipapython/secrets/client.py
Normal file
@ -0,0 +1,99 @@
|
||||
# Copyright (C) 2015 IPA Project Contributors, see COPYING for license
|
||||
|
||||
from __future__ import print_function
|
||||
from custodia.message.kem import KEMClient, KEY_USAGE_SIG, KEY_USAGE_ENC
|
||||
from jwcrypto.common import json_decode
|
||||
from jwcrypto.jwk import JWK
|
||||
from ipapython.secrets.kem import IPAKEMKeys
|
||||
from ipapython.secrets.store import iSecStore
|
||||
from ipaplatform.paths import paths
|
||||
from base64 import b64encode
|
||||
import ldapurl
|
||||
import gssapi
|
||||
import os
|
||||
import requests
|
||||
|
||||
|
||||
class CustodiaClient(object):
|
||||
|
||||
def _client_keys(self):
|
||||
return self.ikk.server_keys
|
||||
|
||||
def _server_keys(self, server, realm):
|
||||
principal = 'host/%s@%s' % (server, realm)
|
||||
sk = JWK(**json_decode(self.ikk.find_key(principal, KEY_USAGE_SIG)))
|
||||
ek = JWK(**json_decode(self.ikk.find_key(principal, KEY_USAGE_ENC)))
|
||||
return (sk, ek)
|
||||
|
||||
def _ldap_uri(self, realm):
|
||||
dashrealm = '-'.join(realm.split('.'))
|
||||
socketpath = paths.SLAPD_INSTANCE_SOCKET_TEMPLATE % (dashrealm,)
|
||||
return 'ldapi://' + ldapurl.ldapUrlEscape(socketpath)
|
||||
|
||||
def _keystore(self, realm, ldap_uri, auth_type):
|
||||
config = dict()
|
||||
if ldap_uri is None:
|
||||
config['ldap_uri'] = self._ldap_uri(realm)
|
||||
else:
|
||||
config['ldap_uri'] = ldap_uri
|
||||
if auth_type is not None:
|
||||
config['auth_type'] = auth_type
|
||||
|
||||
return iSecStore(config)
|
||||
|
||||
def __init__(self, client, server, realm, ldap_uri=None, auth_type=None):
|
||||
self.client = client
|
||||
self.creds = None
|
||||
|
||||
self.service_name = gssapi.Name('HTTP@%s' % (server,),
|
||||
gssapi.NameType.hostbased_service)
|
||||
self.server = server
|
||||
|
||||
keyfile = os.path.join(paths.IPA_CUSTODIA_CONF_DIR, 'server.keys')
|
||||
self.ikk = IPAKEMKeys({'server_keys': keyfile})
|
||||
|
||||
self.kemcli = KEMClient(self._server_keys(server, realm),
|
||||
self._client_keys())
|
||||
|
||||
self.keystore = self._keystore(realm, ldap_uri, auth_type)
|
||||
|
||||
# FIXME: Remove warnings about missig subjAltName
|
||||
requests.packages.urllib3.disable_warnings()
|
||||
|
||||
def init_creds(self):
|
||||
name = gssapi.Name('host@%s' % (self.client,),
|
||||
gssapi.NameType.hostbased_service)
|
||||
store = {'client_keytab': paths.KRB5_KEYTAB,
|
||||
'ccache': 'MEMORY:Custodia_%s' % b64encode(os.urandom(8))}
|
||||
return gssapi.Credentials(name=name, store=store, usage='initiate')
|
||||
|
||||
def _auth_header(self):
|
||||
if not self.creds or self.creds.lifetime < 300:
|
||||
self.creds = self.init_creds()
|
||||
ctx = gssapi.SecurityContext(name=self.service_name, creds=self.creds)
|
||||
authtok = ctx.step()
|
||||
return {'Authorization': 'Negotiate %s' % b64encode(authtok)}
|
||||
|
||||
def fetch_key(self, keyname):
|
||||
|
||||
# Prepare URL
|
||||
url = 'https://%s/ipa/keys/%s' % (self.server, keyname)
|
||||
|
||||
# Prepare signed/encrypted request
|
||||
encalg = ('RSA1_5', 'A256CBC-HS512')
|
||||
request = self.kemcli.make_request(keyname, encalg=encalg)
|
||||
|
||||
# Prepare Authentication header
|
||||
headers = self._auth_header()
|
||||
|
||||
# Perform request
|
||||
r = requests.get(url, headers=headers,
|
||||
params={'type': 'kem', 'value': request})
|
||||
r.raise_for_status()
|
||||
reply = r.json()
|
||||
|
||||
if 'type' not in reply or reply['type'] != 'kem':
|
||||
raise RuntimeError('Invlid JSON response type')
|
||||
|
||||
value = self.kemcli.parse_reply(keyname, reply['value'])
|
||||
self.keystore.set('keys/%s' % keyname, value)
|
45
ipapython/secrets/common.py
Normal file
45
ipapython/secrets/common.py
Normal file
@ -0,0 +1,45 @@
|
||||
# Copyright (C) 2015 IPA Project Contributors, see COPYING for license
|
||||
from __future__ import print_function
|
||||
import ldap
|
||||
import ldap.sasl
|
||||
import ldap.filter
|
||||
|
||||
|
||||
class iSecLdap(object):
|
||||
|
||||
def __init__(self, uri, auth_type=None):
|
||||
self.uri = uri
|
||||
if auth_type is not None:
|
||||
self.auth_type = auth_type
|
||||
else:
|
||||
if uri.startswith('ldapi'):
|
||||
self.auth_type = 'EXTERNAL'
|
||||
else:
|
||||
self.auth_type = 'GSSAPI'
|
||||
self._basedn = None
|
||||
|
||||
@property
|
||||
def basedn(self):
|
||||
if self._basedn is None:
|
||||
conn = self.connect()
|
||||
r = conn.search_s('', ldap.SCOPE_BASE)
|
||||
self._basedn = r[0][1]['defaultnamingcontext'][0]
|
||||
return self._basedn
|
||||
|
||||
def connect(self):
|
||||
conn = ldap.initialize(self.uri)
|
||||
if self.auth_type == 'EXTERNAL':
|
||||
auth_tokens = ldap.sasl.external(None)
|
||||
elif self.auth_type == 'GSSAPI':
|
||||
auth_tokens = ldap.sasl.sasl({}, 'GSSAPI')
|
||||
else:
|
||||
raise ValueError(
|
||||
'Invalid authentication type: %s' % self.auth_type)
|
||||
conn.sasl_interactive_bind_s('', auth_tokens)
|
||||
return conn
|
||||
|
||||
def build_filter(self, formatstr, args):
|
||||
escaped_args = dict()
|
||||
for key, value in args.iteritems():
|
||||
escaped_args[key] = ldap.filter.escape_filter_chars(value)
|
||||
return formatstr.format(**escaped_args)
|
205
ipapython/secrets/kem.py
Normal file
205
ipapython/secrets/kem.py
Normal file
@ -0,0 +1,205 @@
|
||||
# Copyright (C) 2015 IPA Project Contributors, see COPYING for license
|
||||
|
||||
from __future__ import print_function
|
||||
from ipaplatform.paths import paths
|
||||
import ConfigParser
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa, ec
|
||||
from custodia.message.kem import KEMKeysStore
|
||||
from custodia.message.kem import KEY_USAGE_SIG, KEY_USAGE_ENC, KEY_USAGE_MAP
|
||||
from jwcrypto.common import json_decode, json_encode
|
||||
from jwcrypto.common import base64url_encode
|
||||
from jwcrypto.jwk import JWK
|
||||
from ipapython.secrets.common import iSecLdap
|
||||
from binascii import unhexlify
|
||||
import ldap
|
||||
|
||||
|
||||
IPA_REL_BASE_DN = 'cn=custodia,cn=ipa,cn=etc'
|
||||
IPA_KEYS_QUERY = '(&(ipaKeyUsage={usage:s})(memberPrincipal={princ:s}))'
|
||||
RFC5280_USAGE_MAP = {KEY_USAGE_SIG: 'digitalSignature',
|
||||
KEY_USAGE_ENC: 'dataEncipherment'}
|
||||
|
||||
|
||||
class KEMLdap(iSecLdap):
|
||||
|
||||
@property
|
||||
def keysbase(self):
|
||||
return '%s,%s' % (IPA_REL_BASE_DN, self.basedn)
|
||||
|
||||
def _encode_int(self, i):
|
||||
I = hex(i).rstrip("L").lstrip("0x")
|
||||
return base64url_encode(unhexlify((len(I) % 2) * '0' + I))
|
||||
|
||||
def _parse_public_key(self, ipa_public_key):
|
||||
public_key = serialization.load_der_public_key(ipa_public_key,
|
||||
default_backend())
|
||||
num = public_key.public_numbers()
|
||||
if isinstance(num, rsa.RSAPublicNumbers):
|
||||
return {'kty': 'RSA',
|
||||
'e': self._encode_int(num.e),
|
||||
'n': self._encode_int(num.n)}
|
||||
elif isinstance(num, ec.EllipticCurvePublicNumbers):
|
||||
if num.curve.name == 'secp256r1':
|
||||
curve = 'P-256'
|
||||
elif num.curve.name == 'secp384r1':
|
||||
curve = 'P-384'
|
||||
elif num.curve.name == 'secp521r1':
|
||||
curve = 'P-521'
|
||||
else:
|
||||
raise TypeError('Unsupported Elliptic Curve')
|
||||
return {'kty': 'EC',
|
||||
'crv': curve,
|
||||
'x': self._encode_int(num.x),
|
||||
'y': self._encode_int(num.y)}
|
||||
else:
|
||||
raise TypeError('Unknown Public Key type')
|
||||
|
||||
def get_key(self, usage, principal):
|
||||
conn = self.connect()
|
||||
scope = ldap.SCOPE_SUBTREE
|
||||
|
||||
ldap_filter = self.build_filter(IPA_KEYS_QUERY,
|
||||
{'usage': RFC5280_USAGE_MAP[usage],
|
||||
'princ': principal})
|
||||
r = conn.search_s(self.keysbase, scope, ldap_filter)
|
||||
if len(r) != 1:
|
||||
raise ValueError("Incorrect number of results (%d) searching for"
|
||||
"public key for %s" % (len(r), principal))
|
||||
ipa_public_key = r[0][1]['ipaPublicKey'][0]
|
||||
jwk = self._parse_public_key(ipa_public_key)
|
||||
jwk['use'] = KEY_USAGE_MAP[usage]
|
||||
return json_encode(jwk)
|
||||
|
||||
def _format_public_key(self, key):
|
||||
if isinstance(key, str):
|
||||
jwkey = json_decode(key)
|
||||
if 'kty' not in jwkey:
|
||||
raise ValueError('Invalid key, missing "kty" attribute')
|
||||
if jwkey['kty'] == 'RSA':
|
||||
pubnum = rsa.RSAPublicNumbers(jwkey['e'], jwkey['n'])
|
||||
pubkey = pubnum.public_key(default_backend())
|
||||
elif jwkey['kty'] == 'EC':
|
||||
if jwkey['crv'] == 'P-256':
|
||||
curve = ec.SECP256R1
|
||||
elif jwkey['crv'] == 'P-384':
|
||||
curve = ec.SECP384R1
|
||||
elif jwkey['crv'] == 'P-521':
|
||||
curve = ec.SECP521R1
|
||||
else:
|
||||
raise TypeError('Unsupported Elliptic Curve')
|
||||
pubnum = ec.EllipticCurvePublicNumbers(
|
||||
jwkey['x'], jwkey['y'], curve)
|
||||
pubkey = pubnum.public_key(default_backend())
|
||||
else:
|
||||
raise ValueError('Unknown key type: %s' % jwkey['kty'])
|
||||
elif isinstance(key, rsa.RSAPublicKey):
|
||||
pubkey = key
|
||||
elif isinstance(key, ec.EllipticCurvePublicKey):
|
||||
pubkey = key
|
||||
else:
|
||||
raise TypeError('Unknown key type: %s' % type(key))
|
||||
|
||||
return pubkey.public_bytes(
|
||||
encoding=serialization.Encoding.DER,
|
||||
format=serialization.PublicFormat.SubjectPublicKeyInfo)
|
||||
|
||||
def set_key(self, usage, host, principal, key):
|
||||
public_key = self._format_public_key(key)
|
||||
conn = self.connect()
|
||||
name = '%s/%s' % (KEY_USAGE_MAP[usage], host)
|
||||
dn = 'cn=%s,%s' % (name, self.keysbase)
|
||||
try:
|
||||
mods = [('objectClass', ['nsContainer',
|
||||
'ipaKeyPolicy',
|
||||
'ipaPublicKeyObject',
|
||||
'groupOfPrincipals']),
|
||||
('cn', name),
|
||||
('ipaKeyUsage', RFC5280_USAGE_MAP[usage]),
|
||||
('memberPrincipal', principal),
|
||||
('ipaPublicKey', public_key)]
|
||||
conn.add_s(dn, mods)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
# This may fail if the entry already exists
|
||||
mods = [(ldap.MOD_REPLACE, 'memberPrincipal', principal),
|
||||
(ldap.MOD_REPLACE, 'ipaPublicKey', public_key)]
|
||||
conn.modify_s(dn, mods)
|
||||
|
||||
|
||||
def newServerKeys(path, keyid):
|
||||
skey = JWK(generate='RSA', use='sig', kid=keyid)
|
||||
ekey = JWK(generate='RSA', use='enc', kid=keyid)
|
||||
with open(path, 'w+') as f:
|
||||
f.write('[%s,%s]' % (skey.export(), ekey.export()))
|
||||
return [skey.get_op_key('verify'), ekey.get_op_key('encrypt')]
|
||||
|
||||
|
||||
class IPAKEMKeys(KEMKeysStore):
|
||||
"""A KEM Keys Store.
|
||||
|
||||
This is a store that holds public keys of registered
|
||||
clients allowed to use KEM messages. It takes the form
|
||||
of an authorizer merely for the purpose of attaching
|
||||
itself to a 'request' so that later on the KEM Parser
|
||||
can fetch the appropariate key to verify/decrypt an
|
||||
incoming request and make the payload available.
|
||||
|
||||
The KEM Parser will actually perform additional
|
||||
authorization checks in this case.
|
||||
|
||||
SimplePathAuthz is extended here as we want to attach the
|
||||
store only to requests on paths we are configured to
|
||||
manage.
|
||||
"""
|
||||
|
||||
def __init__(self, config=None, ipaconf=paths.IPA_DEFAULT_CONF):
|
||||
super(IPAKEMKeys, self).__init__(config)
|
||||
conf = ConfigParser.ConfigParser()
|
||||
conf.read(ipaconf)
|
||||
self.host = conf.get('global', 'host')
|
||||
self.realm = conf.get('global', 'realm')
|
||||
self.ldap_uri = config.get('ldap_uri', None)
|
||||
if self.ldap_uri is None:
|
||||
self.ldap_uri = conf.get('global', 'ldap_uri', None)
|
||||
self._server_keys = None
|
||||
|
||||
def find_key(self, kid, usage):
|
||||
if kid is None:
|
||||
raise TypeError('Key ID is None, should be a SPN')
|
||||
conn = KEMLdap(self.ldap_uri)
|
||||
return conn.get_key(usage, kid)
|
||||
|
||||
def generate_server_keys(self):
|
||||
principal = 'host/%s@%s' % (self.host, self.realm)
|
||||
# Neutralize the key with read if any
|
||||
self._server_keys = None
|
||||
# Generate private key and store it
|
||||
pubkeys = newServerKeys(self.config['server_keys'], principal)
|
||||
# Store public key in LDAP
|
||||
ldapconn = KEMLdap(self.ldap_uri)
|
||||
ldapconn.set_key(KEY_USAGE_SIG, self.host, principal, pubkeys[0])
|
||||
ldapconn.set_key(KEY_USAGE_ENC, self.host, principal, pubkeys[1])
|
||||
|
||||
@property
|
||||
def server_keys(self):
|
||||
if self._server_keys is None:
|
||||
with open(self.config['server_keys']) as f:
|
||||
jsonkeys = f.read()
|
||||
dictkeys = json_decode(jsonkeys)
|
||||
self._server_keys = (JWK(**dictkeys[KEY_USAGE_SIG]),
|
||||
JWK(**dictkeys[KEY_USAGE_ENC]))
|
||||
return self._server_keys
|
||||
|
||||
|
||||
# Manual testing
|
||||
if __name__ == '__main__':
|
||||
IKK = IPAKEMKeys({'paths': '/',
|
||||
'server_keys': '/etc/ipa/custodia/server.keys'})
|
||||
IKK.generate_server_keys()
|
||||
print(('SIG', IKK.server_keys[0].export_public()))
|
||||
print(('ENC', IKK.server_keys[1].export_public()))
|
||||
print(IKK.find_key('host/%s@%s' % (IKK.host, IKK.realm),
|
||||
usage=KEY_USAGE_SIG))
|
||||
print(IKK.find_key('host/%s@%s' % (IKK.host, IKK.realm),
|
||||
usage=KEY_USAGE_ENC))
|
199
ipapython/secrets/store.py
Normal file
199
ipapython/secrets/store.py
Normal file
@ -0,0 +1,199 @@
|
||||
# Copyright (C) 2015 IPA Project Contributors, see COPYING for license
|
||||
|
||||
from __future__ import print_function
|
||||
from base64 import b64encode, b64decode
|
||||
from custodia.store.interface import CSStore
|
||||
from jwcrypto.common import json_decode, json_encode
|
||||
from ipaplatform.paths import paths
|
||||
from ipapython import ipautil
|
||||
from ipapython.secrets.common import iSecLdap
|
||||
import ldap
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import StringIO
|
||||
import tempfile
|
||||
|
||||
|
||||
class UnknownKeyName(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class DBMAPHandler(object):
|
||||
|
||||
def __init__(self, config, dbmap, nickname):
|
||||
raise NotImplementedError
|
||||
|
||||
def export_key(self):
|
||||
raise NotImplementedError
|
||||
|
||||
def import_key(self, value):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
def log_error(error):
|
||||
print(error, file=sys.stderr)
|
||||
|
||||
|
||||
def PKI_TOMCAT_password_callback():
|
||||
password = None
|
||||
with open(paths.PKI_TOMCAT_PASSWORD_CONF) as f:
|
||||
for line in f.readlines():
|
||||
key, value = line.strip().split('=')
|
||||
if key == 'internal':
|
||||
password = value
|
||||
break
|
||||
return password
|
||||
|
||||
|
||||
def HTTPD_password_callback():
|
||||
with open(paths.ALIAS_PWDFILE_TXT) as f:
|
||||
password = f.read()
|
||||
return password
|
||||
|
||||
|
||||
class NSSCertDB(DBMAPHandler):
|
||||
|
||||
def __init__(self, config, dbmap, nickname):
|
||||
if 'type' not in dbmap or dbmap['type'] != 'NSSDB':
|
||||
raise ValueError('Invalid type "%s",'
|
||||
' expected "NSSDB"' % (dbmap['type'],))
|
||||
if 'path' not in dbmap:
|
||||
raise ValueError('Configuration does not provide NSSDB path')
|
||||
if 'pwcallback' not in dbmap:
|
||||
raise ValueError('Configuration does not provide Password Calback')
|
||||
self.nssdb_path = dbmap['path']
|
||||
self.nickname = nickname
|
||||
self.nssdb_password = dbmap['pwcallback']()
|
||||
|
||||
def export_key(self):
|
||||
tdir = tempfile.mkdtemp(dir=paths.TMP)
|
||||
try:
|
||||
nsspwfile = os.path.join(tdir, 'nsspwfile')
|
||||
with open(nsspwfile, 'w+') as f:
|
||||
f.write(self.nssdb_password)
|
||||
pk12pwfile = os.path.join(tdir, 'pk12pwfile')
|
||||
password = b64encode(os.urandom(16))
|
||||
with open(pk12pwfile, 'w+') as f:
|
||||
f.write(password)
|
||||
pk12file = os.path.join(tdir, 'pk12file')
|
||||
ipautil.run([paths.PK12UTIL,
|
||||
"-d", self.nssdb_path,
|
||||
"-o", pk12file,
|
||||
"-n", self.nickname,
|
||||
"-k", nsspwfile,
|
||||
"-w", pk12pwfile])
|
||||
with open(pk12file, 'r') as f:
|
||||
data = f.read()
|
||||
finally:
|
||||
shutil.rmtree(tdir)
|
||||
return json_encode({'export password': password,
|
||||
'pkcs12 data': b64encode(data)})
|
||||
|
||||
def import_key(self, value):
|
||||
v = json_decode(value)
|
||||
tdir = tempfile.mkdtemp(dir=paths.TMP)
|
||||
try:
|
||||
nsspwfile = os.path.join(tdir, 'nsspwfile')
|
||||
with open(nsspwfile, 'w+') as f:
|
||||
f.write(self.nssdb_password)
|
||||
pk12pwfile = os.path.join(tdir, 'pk12pwfile')
|
||||
with open(pk12pwfile, 'w+') as f:
|
||||
f.write(v['export password'])
|
||||
pk12file = os.path.join(tdir, 'pk12file')
|
||||
with open(pk12file, 'w+') as f:
|
||||
f.write(b64decode(v['pkcs12 data']))
|
||||
ipautil.run([paths.PK12UTIL,
|
||||
"-d", self.nssdb_path,
|
||||
"-i", pk12file,
|
||||
"-n", self.nickname,
|
||||
"-k", nsspwfile,
|
||||
"-w", pk12pwfile])
|
||||
finally:
|
||||
shutil.rmtree(tdir)
|
||||
|
||||
|
||||
# Exfiltrate the DM password Hash so it can be set in replica's and this
|
||||
# way let a replica be install without knowing the DM password and yet
|
||||
# still keep the DM password synchronized across replicas
|
||||
class DMLDAP(DBMAPHandler):
|
||||
|
||||
def __init__(self, config, dbmap, nickname):
|
||||
if 'type' not in dbmap or dbmap['type'] != 'DMLDAP':
|
||||
raise ValueError('Invalid type "%s",'
|
||||
' expected "DMLDAP"' % (dbmap['type'],))
|
||||
if nickname != 'DMHash':
|
||||
raise UnknownKeyName("Unknown Key Named '%s'" % nickname)
|
||||
self.ldap = iSecLdap(config['ldap_uri'],
|
||||
config.get('auth_type', None))
|
||||
|
||||
def export_key(self):
|
||||
conn = self.ldap.connect()
|
||||
r = conn.search_s('cn=config', ldap.SCOPE_BASE,
|
||||
attrlist=['nsslapd-rootpw'])
|
||||
if len(r) != 1:
|
||||
raise RuntimeError('DM Hash not found!')
|
||||
return json_encode({'dmhash': r[0][1]['nsslapd-rootpw'][0]})
|
||||
|
||||
def import_key(self, value):
|
||||
v = json_decode(value)
|
||||
conn = self.ldap.connect()
|
||||
mods = [(ldap.MOD_REPLACE, 'nsslapd-rootpw', str(v['dmhash']))]
|
||||
conn.modify_s('cn=config', mods)
|
||||
|
||||
|
||||
NAME_DB_MAP = {
|
||||
'ca': {
|
||||
'type': 'NSSDB',
|
||||
'path': paths.PKI_TOMCAT_ALIAS_DIR,
|
||||
'handler': NSSCertDB,
|
||||
'pwcallback': PKI_TOMCAT_password_callback,
|
||||
},
|
||||
'ra': {
|
||||
'type': 'NSSDB',
|
||||
'path': paths.HTTPD_ALIAS_DIR,
|
||||
'handler': NSSCertDB,
|
||||
'pwcallback': HTTPD_password_callback,
|
||||
},
|
||||
'dm': {
|
||||
'type': 'DMLDAP',
|
||||
'handler': DMLDAP,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class iSecStore(CSStore):
|
||||
|
||||
def __init__(self, config=None):
|
||||
self.config = config
|
||||
|
||||
def _get_handler(self, key):
|
||||
path = key.split('/', 3)
|
||||
if len(path) != 3 or path[0] != 'keys':
|
||||
raise ValueError('Invalid name')
|
||||
if path[1] not in NAME_DB_MAP:
|
||||
raise UnknownKeyName("Unknown DB named '%s'" % path[1])
|
||||
dbmap = NAME_DB_MAP[path[1]]
|
||||
return dbmap['handler'](self.config, dbmap, path[2])
|
||||
|
||||
def get(self, key):
|
||||
try:
|
||||
key_handler = self._get_handler(key)
|
||||
value = key_handler.export_key()
|
||||
except Exception as e: # pylint: disable=broad-except
|
||||
log_error('Error retrievieng key "%s": %s' % (key, str(e)))
|
||||
value = None
|
||||
return value
|
||||
|
||||
def set(self, key, value, replace=False):
|
||||
try:
|
||||
key_handler = self._get_handler(key)
|
||||
key_handler.import_key(value)
|
||||
except Exception as e: # pylint: disable=broad-except
|
||||
log_error('Error storing key "%s": %s' % (key, str(e)))
|
||||
|
||||
def list(self, keyfilter=None):
|
||||
raise NotImplementedError
|
||||
|
||||
def cut(self, key):
|
||||
raise NotImplementedError
|
1
ipapython/setup.py.in
Normal file → Executable file
1
ipapython/setup.py.in
Normal file → Executable file
@ -67,6 +67,7 @@ def setup_package():
|
||||
package_dir = {'ipapython': ''},
|
||||
packages = ["ipapython",
|
||||
"ipapython.dnssec",
|
||||
"ipapython.secrets",
|
||||
"ipapython.install"],
|
||||
)
|
||||
finally:
|
||||
|
51
ipaserver/install/custodiainstance.py
Normal file
51
ipaserver/install/custodiainstance.py
Normal file
@ -0,0 +1,51 @@
|
||||
# Copyright (C) 2015 FreeIPa Project Contributors, see 'COPYING' for license.
|
||||
|
||||
from ipapython.secrets.kem import IPAKEMKeys
|
||||
from ipaplatform.paths import paths
|
||||
from service import SimpleServiceInstance
|
||||
from ipapython import ipautil
|
||||
from ipaserver.install import installutils
|
||||
import os
|
||||
|
||||
|
||||
class CustodiaInstance(SimpleServiceInstance):
|
||||
def __init__(self):
|
||||
super(CustodiaInstance, self).__init__("ipa-custodia")
|
||||
self.config_file = paths.IPA_CUSTODIA_CONF
|
||||
self.server_keys = os.path.join(paths.IPA_CUSTODIA_CONF_DIR,
|
||||
'server.keys')
|
||||
|
||||
def __config_file(self):
|
||||
template_file = os.path.basename(self.config_file) + '.template'
|
||||
template = os.path.join(ipautil.SHARE_DIR, template_file)
|
||||
sub_dict = dict(IPA_CUSTODIA_CONF_DIR=paths.IPA_CUSTODIA_CONF_DIR,
|
||||
IPA_CUSTODIA_SOCKET=paths.IPA_CUSTODIA_SOCKET,
|
||||
IPA_CUSTODIA_AUDIT_LOG=paths.IPA_CUSTODIA_AUDIT_LOG,
|
||||
LDAP_URI=installutils.realm_to_ldapi_uri(self.realm))
|
||||
conf = ipautil.template_file(template, sub_dict)
|
||||
fd = open(self.config_file, "w+")
|
||||
fd.write(conf)
|
||||
fd.flush()
|
||||
fd.close()
|
||||
|
||||
def create_instance(self, *args, **kwargs):
|
||||
self.step("Generating ipa-custodia config file", self.__config_file)
|
||||
self.step("Generating ipa-custodia keys", self.__gen_keys)
|
||||
super(CustodiaInstance, self).create_instance(*args, **kwargs)
|
||||
|
||||
def __gen_keys(self):
|
||||
KeyStore = IPAKEMKeys({'server_keys': self.server_keys})
|
||||
KeyStore.generate_server_keys()
|
||||
|
||||
def upgrade_instance(self, realm):
|
||||
self.realm = realm
|
||||
if not os.path.exists(self.config_file):
|
||||
self.__config_file()
|
||||
if not os.path.exists(self.server_keys):
|
||||
self.__gen_keys()
|
||||
|
||||
def __start(self):
|
||||
super(CustodiaInstance, self).__start()
|
||||
|
||||
def __enable(self):
|
||||
super(CustodiaInstance, self).__enable()
|
@ -35,6 +35,7 @@ from contextlib import contextmanager
|
||||
from dns import resolver, rdatatype
|
||||
from dns.exception import DNSException
|
||||
import ldap
|
||||
import ldapurl
|
||||
from nss.error import NSPRError
|
||||
import six
|
||||
from six.moves.configparser import SafeConfigParser, NoOptionError
|
||||
@ -1097,6 +1098,13 @@ def check_version():
|
||||
def realm_to_serverid(realm_name):
|
||||
return "-".join(realm_name.split("."))
|
||||
|
||||
|
||||
def realm_to_ldapi_uri(realm_name):
|
||||
serverid = realm_to_serverid(realm_name)
|
||||
socketname = paths.SLAPD_INSTANCE_SOCKET_TEMPLATE % (serverid,)
|
||||
return 'ldapi://' + ldapurl.ldapUrlEscape(socketname)
|
||||
|
||||
|
||||
def enable_and_start_oddjobd(sstore):
|
||||
oddjobd = services.service('oddjobd')
|
||||
sstore.backup_state('oddjobd', 'running', oddjobd.is_running())
|
||||
|
@ -33,7 +33,7 @@ import ipaclient.ntpconf
|
||||
from ipaserver.install import (
|
||||
bindinstance, ca, cainstance, certs, dns, dsinstance, httpinstance,
|
||||
installutils, kra, krbinstance, memcacheinstance, ntpinstance,
|
||||
otpdinstance, replication, service, sysupgrade)
|
||||
otpdinstance, custodiainstance, replication, service, sysupgrade)
|
||||
from ipaserver.install.installutils import (
|
||||
IPA_MODULES, BadHostError, get_fqdn, get_server_ip_address,
|
||||
is_ipa_configured, load_pkcs12, read_password, verify_fqdn,
|
||||
@ -814,6 +814,11 @@ def install(installer):
|
||||
otpd.create_instance('OTPD', host_name, dm_password,
|
||||
ipautil.realm_to_suffix(realm_name))
|
||||
|
||||
custodia = custodiainstance.CustodiaInstance()
|
||||
custodia.create_instance('KEYS', host_name, dm_password,
|
||||
ipautil.realm_to_suffix(realm_name),
|
||||
realm_name)
|
||||
|
||||
# Create a HTTP instance
|
||||
http = httpinstance.HTTPInstance(fstore)
|
||||
if options.http_cert_files:
|
||||
@ -1078,6 +1083,7 @@ def uninstall(installer):
|
||||
dsinstance.DsInstance(fstore=fstore).uninstall()
|
||||
if _server_trust_ad_installed:
|
||||
adtrustinstance.ADTRUSTInstance(fstore).uninstall()
|
||||
custodiainstance.CustodiaInstance().uninstall()
|
||||
memcacheinstance.MemcacheInstance().uninstall()
|
||||
otpdinstance.OtpdInstance().uninstall()
|
||||
tasks.restore_network_configuration(fstore, sstore)
|
||||
|
@ -28,7 +28,7 @@ import ipaclient.ntpconf
|
||||
from ipaserver.install import (
|
||||
bindinstance, ca, cainstance, certs, dns, dsinstance, httpinstance,
|
||||
installutils, kra, krbinstance, memcacheinstance, ntpinstance,
|
||||
otpdinstance, service)
|
||||
otpdinstance, custodiainstance, service)
|
||||
from ipaserver.install.installutils import create_replica_config
|
||||
from ipaserver.install.replication import (
|
||||
ReplicationManager, replica_conn_check)
|
||||
@ -596,6 +596,13 @@ def install(installer):
|
||||
CA.import_ra_cert(config.dir + "/ra.p12")
|
||||
CA.fix_ra_perms()
|
||||
|
||||
# FIXME: must be done earlier in replica to fetch keys for CA/ldap server
|
||||
# before they are configured
|
||||
custodia = custodiainstance.CustodiaInstance()
|
||||
custodia.create_instance('KEYS', config.host_name,
|
||||
config.dirman_password,
|
||||
ipautil.realm_to_suffix(config.realm_name))
|
||||
|
||||
# The DS instance is created before the keytab, add the SSL cert we
|
||||
# generated
|
||||
ds.add_cert_to_service()
|
||||
|
@ -36,6 +36,7 @@ from ipaserver.install import cainstance
|
||||
from ipaserver.install import certs
|
||||
from ipaserver.install import otpdinstance
|
||||
from ipaserver.install import schemaupdate
|
||||
from ipaserver.install import custodiainstance
|
||||
from ipaserver.install import sysupgrade
|
||||
from ipaserver.install import dnskeysyncinstance
|
||||
from ipaserver.install import krainstance
|
||||
@ -1490,7 +1491,7 @@ def upgrade_configuration():
|
||||
service.ldapi = True
|
||||
try:
|
||||
if not service.is_configured():
|
||||
# 389-ds needs to be running to create the memcache instance
|
||||
# 389-ds needs to be running to create the instances
|
||||
# because we record the new service in cn=masters.
|
||||
ds.start()
|
||||
service.create_instance(ldap_name, fqdn, None,
|
||||
@ -1539,6 +1540,9 @@ def upgrade_configuration():
|
||||
except ipautil.CalledProcessError as e:
|
||||
root_logger.error("Failed to restart %s: %s", bind.service_name, e)
|
||||
|
||||
custodia = custodiainstance.CustodiaInstance()
|
||||
custodia.upgrade_instance(api.env.realm)
|
||||
|
||||
ca_restart = any([
|
||||
ca_restart,
|
||||
ca_upgrade_schema(ca),
|
||||
|
@ -40,6 +40,7 @@ SERVICE_LIST = {
|
||||
'DNS': ('named', 30),
|
||||
'MEMCACHE': ('ipa_memcached', 39),
|
||||
'HTTP': ('httpd', 40),
|
||||
'KEYS': ('ipa-custodia', 41),
|
||||
'CA': ('%sd' % dogtag.configured_constants().PKI_INSTANCE_NAME, 50),
|
||||
'KRA': ('%sd' % dogtag.configured_constants().PKI_INSTANCE_NAME, 51),
|
||||
'ADTRUST': ('smb', 60),
|
||||
|
55
ipatests/test_ipapython/test_secrets.py
Normal file
55
ipatests/test_ipapython/test_secrets.py
Normal file
@ -0,0 +1,55 @@
|
||||
# Copyright (C) 2015 FreeIPA Project Contributors - see LICENSE file
|
||||
|
||||
from __future__ import print_function
|
||||
from ipapython.secrets.store import iSecStore, NAME_DB_MAP, NSSCertDB
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import unittest
|
||||
|
||||
|
||||
def _test_password_callback():
|
||||
with open('test-ipa-sec-store/pwfile') as f:
|
||||
password = f.read()
|
||||
return password
|
||||
|
||||
|
||||
class TestiSecStore(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
try:
|
||||
shutil.rmtree('test-ipa-sec-store')
|
||||
except Exception: # pylint: disable=broad-except
|
||||
pass
|
||||
testdir = 'test-ipa-sec-store'
|
||||
pwfile = os.path.join(testdir, 'pwfile')
|
||||
os.mkdir(testdir)
|
||||
with open(pwfile, 'w') as f:
|
||||
f.write('testpw')
|
||||
cls.certdb = os.path.join(testdir, 'certdb')
|
||||
os.mkdir(cls.certdb)
|
||||
cls.cert2db = os.path.join(testdir, 'cert2db')
|
||||
os.mkdir(cls.cert2db)
|
||||
seedfile = os.path.join(testdir, 'seedfile')
|
||||
with open(seedfile, 'w') as f:
|
||||
seed = os.urandom(1024)
|
||||
f.write(seed)
|
||||
subprocess.call(['certutil', '-d', cls.certdb, '-N', '-f', pwfile])
|
||||
subprocess.call(['certutil', '-d', cls.cert2db, '-N', '-f', pwfile])
|
||||
subprocess.call(['certutil', '-d', cls.certdb, '-S', '-f', pwfile,
|
||||
'-s', 'CN=testCA', '-n', 'testCACert', '-x',
|
||||
'-t', 'CT,C,C', '-m', '1', '-z', seedfile])
|
||||
|
||||
def test_iSecStore(self):
|
||||
iss = iSecStore({})
|
||||
|
||||
NAME_DB_MAP['test'] = {
|
||||
'type': 'NSSDB',
|
||||
'path': self.certdb,
|
||||
'handler': NSSCertDB,
|
||||
'pwcallback': _test_password_callback,
|
||||
}
|
||||
value = iss.get('keys/test/testCACert')
|
||||
|
||||
NAME_DB_MAP['test']['path'] = self.cert2db
|
||||
iss.set('keys/test/testCACert', value)
|
Loading…
Reference in New Issue
Block a user