Skip to content

Commit e842535

Browse files
Erick Tryzelaarromainr
authored andcommitted
HUE-3102 [libsaml] Add support for private key passwords
This is also tracked by IdentityPython/pysaml2#278. This patch adds support for SAML certificates that are protected with a password. The way it does so is with a bit of trickiness, due to the fact that `xmlsec1`, which is an external program that pysaml2 uses to sign the XML requests, which does not have great support for password protected certificates. It either supports passing in the password on the command line (which is not safe since someone else on the machine could see the password), or through an interactive prompt. The proper way to fix this would be to update pysaml2 to use another xmlsec library, but implementing that may take some time. In the short/medium term, this patch implements this instead by decrypting the certificate in memory, and passing this decrypted certificate to xmlsec1 through a named pipe. This protects us from the decrypted certificate ever hitting the disk. Unfortunately, this solution is only portable to POSIX-compatible platforms. That is fine for Hue, but it probably means we cannot push this patch to the upstream pysaml2 repository. This patch will tied us over until the upstream project switches to a better xmlsec library.
1 parent 86d3302 commit e842535

File tree

5 files changed

+128
-27
lines changed

5 files changed

+128
-27
lines changed

desktop/core/ext-py/pysaml2-2.4.0/src/saml2/config.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,20 @@
4848
}
4949

5050
COMMON_ARGS = [
51-
"entityid", "xmlsec_binary", "debug", "key_file", "cert_file",
52-
"encryption_type", "secret", "accepted_time_diff", "name", "ca_certs",
53-
"description", "valid_for", "verify_ssl_cert",
51+
"entityid",
52+
"xmlsec_binary",
53+
"debug",
54+
"key_file",
55+
"key_file_passphrase",
56+
"cert_file",
57+
"encryption_type",
58+
"secret",
59+
"accepted_time_diff",
60+
"name",
61+
"ca_certs",
62+
"description",
63+
"valid_for",
64+
"verify_ssl_cert",
5465
"organization",
5566
"contact_person",
5667
"name_form",
@@ -185,6 +196,7 @@ def __init__(self, homedir="."):
185196
self.xmlsec_path = []
186197
self.debug = False
187198
self.key_file = None
199+
self.key_file_passphrase = None
188200
self.cert_file = None
189201
self.encryption_type = 'both'
190202
self.secret = None

desktop/core/ext-py/pysaml2-2.4.0/src/saml2/entity.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,8 @@ def __init__(self, entity_type, config=None, config_file="",
136136

137137
try:
138138
self.signkey = RSA.importKey(
139-
open(self.config.getattr("key_file", ""), 'r').read())
139+
open(self.config.getattr("key_file", ""), 'r').read(),
140+
passphrase=self.config.key_file_passphrase)
140141
except (KeyError, TypeError):
141142
self.signkey = None
142143

desktop/core/ext-py/pysaml2-2.4.0/src/saml2/sigver.py

Lines changed: 88 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@
4040
from saml2.time_util import utc_now
4141
from saml2.time_util import str_to_time
4242

43-
from tempfile import NamedTemporaryFile
43+
from tempfile import NamedTemporaryFile, mkdtemp
4444
from subprocess import Popen, PIPE
4545

4646
from xmldsig import SIG_RSA_SHA1
@@ -563,6 +563,24 @@ def parse_xmlsec_output(output):
563563
def sha1_digest(msg):
564564
return hashlib.sha1(msg).digest()
565565

566+
# --------------------------------------------------------------------------
567+
568+
class NamedPipe(object):
569+
def __init__(self):
570+
self._tempdir = mkdtemp()
571+
self.name = os.path.join(self._tempdir, 'fifo')
572+
573+
try:
574+
os.mkfifo(self.name)
575+
except:
576+
os.rmdir(self._tempdir)
577+
578+
def close(self):
579+
os.remove(self.name)
580+
os.rmdir(self._tempdir)
581+
582+
# --------------------------------------------------------------------------
583+
566584

567585
class Signer(object):
568586
"""Abstract base class for signing algorithms."""
@@ -699,7 +717,7 @@ def encrypt_assertion(self, statement, enc_key, template, key_type,
699717
node_xpath):
700718
raise NotImplementedError()
701719

702-
def decrypt(self, enctext, key_file):
720+
def decrypt(self, enctext, key_file, passphrase=None):
703721
raise NotImplementedError()
704722

705723
def sign_statement(self, statement, node_name, key_file, node_id,
@@ -799,7 +817,7 @@ def encrypt_assertion(self, statement, enc_key, template,
799817

800818
return output
801819

802-
def decrypt(self, enctext, key_file):
820+
def decrypt(self, enctext, key_file, passphrase=None):
803821
"""
804822
805823
:param enctext: XML document containing an encrypted part
@@ -810,16 +828,19 @@ def decrypt(self, enctext, key_file):
810828
logger.debug("Decrypt input len: %d" % len(enctext))
811829
_, fil = make_temp("%s" % enctext, decode=False)
812830

813-
com_list = [self.xmlsec, "--decrypt", "--privkey-pem",
814-
key_file, "--id-attr:%s" % ID_ATTR, ENC_KEY_CLASS]
831+
com_list = [self.xmlsec, "--decrypt", "--id-attr:%s" % ID_ATTR,
832+
ENC_KEY_CLASS]
815833

816834
(_stdout, _stderr, output) = self._run_xmlsec(com_list, [fil],
817835
exception=DecryptError,
818-
validate_output=False)
836+
validate_output=False,
837+
key_file=key_file,
838+
passphrase=passphrase)
839+
819840
return output
820841

821842
def sign_statement(self, statement, node_name, key_file, node_id,
822-
id_attr):
843+
id_attr, passphrase=None):
823844
"""
824845
Sign an XML statement.
825846
@@ -832,18 +853,18 @@ def sign_statement(self, statement, node_name, key_file, node_id,
832853
:return: The signed statement
833854
"""
834855

835-
_, fil = make_temp("%s" % statement, suffix=".xml", decode=False,
856+
_, fil = make_temp("%s" % statement, suffix=".xml", decode=False,
836857
delete=self._xmlsec_delete_tmpfiles)
837858

838859
com_list = [self.xmlsec, "--sign",
839-
"--privkey-pem", key_file,
840860
"--id-attr:%s" % id_attr, node_name]
841861
if node_id:
842862
com_list.extend(["--node-id", node_id])
843863

844864
try:
845865
(stdout, stderr, signed_statement) = self._run_xmlsec(
846-
com_list, [fil], validate_output=False)
866+
com_list, [fil], validate_output=False, key_file=key_file,
867+
passphrase=passphrase)
847868
# this doesn't work if --store-signatures are used
848869
if stdout == "":
849870
if signed_statement:
@@ -898,7 +919,9 @@ def validate_signature(self, signedtext, cert_file, cert_type, node_name,
898919
return parse_xmlsec_output(stderr)
899920

900921
def _run_xmlsec(self, com_list, extra_args, validate_output=True,
901-
exception=XmlsecError):
922+
exception=XmlsecError,
923+
key_file=None,
924+
passphrase=None):
902925
"""
903926
Common code to invoke xmlsec and parse the output.
904927
:param com_list: Key-value parameter list for xmlsec
@@ -910,13 +933,40 @@ def _run_xmlsec(self, com_list, extra_args, validate_output=True,
910933
"""
911934
ntf = NamedTemporaryFile(suffix=".xml",
912935
delete=self._xmlsec_delete_tmpfiles)
936+
913937
com_list.extend(["--output", ntf.name])
938+
939+
# Unfortunately there's no safe way to pass a password to xmlsec1.
940+
# Instead, we'll decrypt the certificate and write it into a named pipe,
941+
# which we'll pass to xmlsec1.
942+
named_pipe = None
943+
if key_file is not None:
944+
if passphrase is not None:
945+
named_pipe = NamedPipe()
946+
947+
# Decrypt the certificate, but don't write it into the FIFO
948+
# until after we've started xmlsec1.
949+
with open(key_file) as f:
950+
key = importKey(f.read(), passphrase=passphrase)
951+
952+
key_file = named_pipe.name
953+
954+
com_list.extend(["--privkey-pem", key_file])
955+
914956
com_list += extra_args
915957

916958
logger.debug("xmlsec command: %s" % " ".join(com_list))
917959

918960
pof = Popen(com_list, stderr=PIPE, stdout=PIPE)
919961

962+
if named_pipe is not None:
963+
# Finally, write the key into our named pipe.
964+
try:
965+
with open(named_pipe.name, 'wb') as f:
966+
f.write(key.exportKey())
967+
finally:
968+
named_pipe.close()
969+
920970
p_out = pof.stdout.read()
921971
p_err = pof.stderr.read()
922972

@@ -958,7 +1008,7 @@ def version(self):
9581008
return "XMLSecurity 0.0"
9591009

9601010
def sign_statement(self, statement, node_name, key_file, node_id,
961-
_id_attr):
1011+
_id_attr, passphrase=None):
9621012
"""
9631013
Sign an XML statement.
9641014
@@ -974,6 +1024,8 @@ def sign_statement(self, statement, node_name, key_file, node_id,
9741024
import xmlsec
9751025
import lxml.etree
9761026

1027+
assert passphrase is None, "Encrypted key files is not supported"
1028+
9771029
xml = xmlsec.parse_xml(statement)
9781030
signed = xmlsec.sign(xml, key_file)
9791031
return lxml.etree.tostring(signed, xml_declaration=True)
@@ -1055,7 +1107,8 @@ def security_context(conf, debug=None):
10551107
generate_cert_info=conf.generate_cert_info,
10561108
tmp_cert_file=conf.tmp_cert_file,
10571109
tmp_key_file=conf.tmp_key_file,
1058-
validate_certificate=conf.validate_certificate)
1110+
validate_certificate=conf.validate_certificate,
1111+
key_file_passphrase=conf.key_file_passphrase)
10591112

10601113

10611114
def encrypt_cert_from_item(item):
@@ -1218,13 +1271,15 @@ def __init__(self, crypto, key_file="", key_type="pem",
12181271
debug=False, template="", encrypt_key_type="des-192",
12191272
only_use_keys_in_metadata=False, cert_handler_extra_class=None,
12201273
generate_cert_info=None, tmp_cert_file=None,
1221-
tmp_key_file=None, validate_certificate=None):
1274+
tmp_key_file=None, validate_certificate=None,
1275+
key_file_passphrase=None):
12221276

12231277
self.crypto = crypto
12241278
assert (isinstance(self.crypto, CryptoBackend))
12251279

12261280
# Your private key
12271281
self.key_file = key_file
1282+
self.key_file_passphrase = key_file_passphrase
12281283
self.key_type = key_type
12291284

12301285
# Your public key
@@ -1293,15 +1348,19 @@ def encrypt_assertion(self, statement, enc_key, template,
12931348
"""
12941349
raise NotImplemented()
12951350

1296-
def decrypt(self, enctext, key_file=None):
1351+
def decrypt(self, enctext, key_file=None, passphrase=None):
12971352
""" Decrypting an encrypted text by the use of a private key.
12981353
12991354
:param enctext: The encrypted text as a string
13001355
:return: The decrypted text
13011356
"""
1302-
if key_file is not None and len(key_file.strip()) > 0:
1303-
return self.crypto.decrypt(enctext, key_file)
1304-
return self.crypto.decrypt(enctext, self.key_file)
1357+
if key_file is None or len(key_file.strip()) == 0:
1358+
key_file = self.key_file
1359+
1360+
if passphrase is None:
1361+
passphrase = self.key_file_passphrase
1362+
1363+
return self.crypto.decrypt(enctext, key_file, passphrase)
13051364

13061365
def verify_signature(self, signedtext, cert_file=None, cert_type="pem",
13071366
node_name=NODE_NAME, node_id=None, id_attr=""):
@@ -1619,7 +1678,8 @@ def sign_statement_using_xmlsec(self, statement, **kwargs):
16191678
return self.sign_statement(statement, **kwargs)
16201679

16211680
def sign_statement(self, statement, node_name, key=None,
1622-
key_file=None, node_id=None, id_attr=""):
1681+
key_file=None, node_id=None, id_attr="",
1682+
passphrase=None):
16231683
"""Sign a SAML statement.
16241684
16251685
:param statement: The statement to be signed
@@ -1640,8 +1700,12 @@ def sign_statement(self, statement, node_name, key=None,
16401700
if not key and not key_file:
16411701
key_file = self.key_file
16421702

1703+
if not passphrase:
1704+
passphrase = self.key_file_passphrase
1705+
16431706
return self.crypto.sign_statement(statement, node_name, key_file,
1644-
node_id, id_attr)
1707+
node_id, id_attr,
1708+
passphrase=passphrase)
16451709

16461710
def sign_assertion_using_xmlsec(self, statement, **kwargs):
16471711
""" Deprecated function. See sign_assertion(). """
@@ -1674,7 +1738,8 @@ def sign_attribute_query(self, statement, **kwargs):
16741738
return self.sign_statement(statement, class_name(
16751739
samlp.AttributeQuery()), **kwargs)
16761740

1677-
def multiple_signatures(self, statement, to_sign, key=None, key_file=None):
1741+
def multiple_signatures(self, statement, to_sign, key=None, key_file=None,
1742+
passphrase=None):
16781743
"""
16791744
Sign multiple parts of a statement
16801745
@@ -1697,7 +1762,8 @@ def multiple_signatures(self, statement, to_sign, key=None, key_file=None):
16971762

16981763
statement = self.sign_statement(statement, class_name(item),
16991764
key=key, key_file=key_file,
1700-
node_id=sid, id_attr=id_attr)
1765+
node_id=sid, id_attr=id_attr,
1766+
passphrase=passphrase)
17011767
return statement
17021768

17031769

desktop/libs/libsaml/src/libsaml/conf.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121

2222
from django.utils.translation import ugettext_lazy as _t, ugettext as _
2323

24-
from desktop.lib.conf import Config, coerce_bool, coerce_csv
24+
from desktop.lib.conf import Config, coerce_bool, coerce_csv, coerce_password_from_script
2525

2626

2727
BASEDIR = os.path.dirname(os.path.abspath(__file__))
@@ -113,6 +113,17 @@ def dict_list_map(value):
113113
type=str,
114114
help=_t("key_file is the name of a PEM formatted file that contains the private key of the Hue service. This is presently used both to encrypt/sign assertions and as client key in a HTTPS session."))
115115

116+
KEY_FILE_PASSWORD = Config(
117+
key="key_file_password",
118+
help=_t("key_file_password password of the private key"),
119+
default=None)
120+
121+
KEY_FILE_PASSWORD_SCRIPT = Config(
122+
key="key_file_password_script",
123+
help=_t("Execute this script to produce the private key password. This will be used when `key_file_password` is not set."),
124+
type=coerce_password_from_script,
125+
default=None)
126+
116127
CERT_FILE = Config(
117128
key="cert_file",
118129
default="",
@@ -155,6 +166,16 @@ def dict_list_map(value):
155166
type=str,
156167
help=_t("Request this NameID format from the server"))
157168

169+
def get_key_file_password():
170+
password = os.environ.get('HUE_SAML_KEY_FILE_PASSWORD')
171+
if password is not None:
172+
return password
173+
174+
password = KEY_FILE_PASSWORD.get()
175+
if not password:
176+
password = KEY_FILE_PASSWORD_SCRIPT.get()
177+
178+
return password
158179

159180
def config_validator(user):
160181
res = []

desktop/libs/libsaml/src/libsaml/saml_settings.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ def config_settings_loader(request):
9393

9494
# certificate
9595
'key_file': libsaml.conf.KEY_FILE.get(),
96+
'key_file_passphrase': libsaml.conf.get_key_file_password(),
9697
'cert_file': libsaml.conf.CERT_FILE.get()
9798
})
9899

0 commit comments

Comments
 (0)