From fe799793a4aced0cf8a21bb1c4c401215e04c8c6 Mon Sep 17 00:00:00 2001 From: Scott Cantor Date: Tue, 28 Oct 2008 17:50:00 +0000 Subject: [PATCH] Multi-line svn commit, see body. Initial set of helper functions to handle key/cert loading. Additional unit tests and data. --- .cproject | 3 + xmltooling/Makefile.am | 2 + xmltooling/security/SecurityHelper.h | 122 ++++++++ xmltooling/security/impl/SecurityHelper.cpp | 436 ++++++++++++++++++++++++++++ xmltoolingtest/Makefile.am | 1 + xmltoolingtest/SecurityHelperTest.h | 66 +++++ xmltoolingtest/data/cert.der | Bin 0 -> 659 bytes xmltoolingtest/data/key.der | Bin 0 -> 609 bytes xmltoolingtest/data/test.pfx | Bin 0 -> 1677 bytes 9 files changed, 630 insertions(+) create mode 100644 xmltooling/security/SecurityHelper.h create mode 100644 xmltooling/security/impl/SecurityHelper.cpp create mode 100644 xmltoolingtest/SecurityHelperTest.h create mode 100644 xmltoolingtest/data/cert.der create mode 100644 xmltoolingtest/data/key.der create mode 100644 xmltoolingtest/data/test.pfx diff --git a/.cproject b/.cproject index 2473079..b78749e 100644 --- a/.cproject +++ b/.cproject @@ -61,6 +61,7 @@ + @@ -73,6 +74,8 @@ + + diff --git a/xmltooling/Makefile.am b/xmltooling/Makefile.am index a580e53..87a5bae 100644 --- a/xmltooling/Makefile.am +++ b/xmltooling/Makefile.am @@ -77,6 +77,7 @@ secinclude_HEADERS = \ security/KeyInfoCredentialContext.h \ security/KeyInfoResolver.h \ security/OpenSSLCredential.h \ + security/SecurityHelper.h \ security/SignatureTrustEngine.h \ security/TrustEngine.h \ security/X509Credential.h \ @@ -141,6 +142,7 @@ xmlsec_sources = \ security/impl/InlineKeyResolver.cpp \ security/impl/KeyInfoResolver.cpp \ security/impl/OpenSSLCryptoX509CRL.cpp \ + security/impl/SecurityHelper.cpp \ security/impl/StaticPKIXTrustEngine.cpp \ security/impl/TrustEngine.cpp \ security/impl/XSECCryptoX509CRL.cpp \ diff --git a/xmltooling/security/SecurityHelper.h b/xmltooling/security/SecurityHelper.h new file mode 100644 index 0000000..4c0bbf9 --- /dev/null +++ b/xmltooling/security/SecurityHelper.h @@ -0,0 +1,122 @@ +/* + * Copyright 2001-2008 Internet2 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * @file xmltooling/security/SecurityHelper.h + * + * A helper class for working with keys, certificates, etc. + */ + +#if !defined(__xmltooling_sechelper_h__) && !defined(XMLTOOLING_NO_XMLSEC) +#define __xmltooling_sechelper_h__ + +#include +#include + +#include +#include +#include + +namespace xmltooling { + /** + * A helper class for working with keys, certificates, etc. + */ + class XMLTOOL_API SecurityHelper + { + public: + /** + * Loads a private key from a local file. + * + * @param pathname path to file containing key + * @param format optional constant identifying key encoding format + * @param password optional password to decrypt key + * @return a populated key object + */ + static XSECCryptoKey* loadKeyFromFile(const char* pathname, const char* format=NULL, const char* password=NULL); + + /** + * Loads certificate(s) from a local file. + * + * @param certs array to populate with certificate(s) + * @param pathname path to file containing certificate(s) + * @param format optional constant identifying certificate encoding format + * @return size of the resulting array + */ + static std::vector::size_type loadCertificatesFromFile( + std::vector& certs, const char* pathname, const char* format=NULL, const char* password=NULL + ); + + /** + * Loads CRL(s) from a local file. + * + * @param crls array to populate with CRL(s) + * @param pathname path to file containing CRL(s) + * @param format optional constant identifying CRL encoding format + * @return size of the resulting array + */ + static std::vector::size_type loadCRLsFromFile( + std::vector& crls, const char* pathname, const char* format=NULL + ); + + /** + * Loads a private key from a URL. + * + * @param transport object to use to acquire key + * @param backing backing file for key (written to or read from if download fails) + * @param format optional constant identifying key encoding format + * @param password optional password to decrypt key + * @return a populated key object + */ + static XSECCryptoKey* loadKeyFromURL(SOAPTransport& transport, const char* backing, const char* format=NULL, const char* password=NULL); + + /** + * Loads certificate(s) from a URL. + * + * @param certs array to populate with certificate(s) + * @param transport object to use to acquire certificate(s) + * @param backing backing file for certificate(s) (written to or read from if download fails) + * @param format optional constant identifying certificate encoding format + * @return size of the resulting array + */ + static std::vector::size_type loadCertificatesFromURL( + std::vector& certs, SOAPTransport& transport, const char* backing, const char* format=NULL, const char* password=NULL + ); + + /** + * Loads CRL(s) from a URL. + * + * @param crls array to populate with CRL(s) + * @param transport object to use to acquire CRL(s) + * @param backing backing file for CRL(s) (written to or read from if download fails) + * @param format optional constant identifying CRL encoding format + * @return size of the resulting array + */ + static std::vector::size_type loadCRLsFromURL( + std::vector& crls, SOAPTransport& transport, const char* backing, const char* format=NULL + ); + + /** + * Compares two keys for equality. + * + * @param key1 first key to compare + * @param key2 second key to compare + * @return true iff the keys match + */ + static bool matches(const XSECCryptoKey* key1, const XSECCryptoKey* key2); + }; +}; + +#endif /* __xmltooling_sechelper_h__ */ diff --git a/xmltooling/security/impl/SecurityHelper.cpp b/xmltooling/security/impl/SecurityHelper.cpp new file mode 100644 index 0000000..5c39bf8 --- /dev/null +++ b/xmltooling/security/impl/SecurityHelper.cpp @@ -0,0 +1,436 @@ +/* + * Copyright 2001-2008 Internet2 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * SecurityHelper.cpp + * + * A helper class for working with keys, certificates, etc. + */ + +#include "internal.h" +#include "logging.h" +#include "security/OpenSSLCryptoX509CRL.h" +#include "security/SecurityHelper.h" +#include "util/NDC.h" + +#include +#include +#include +#include +#include +#include + +using namespace xmltooling::logging; +using namespace xmltooling; +using namespace std; + +// OpenSSL password callback... +static int passwd_callback(char* buf, int len, int verify, void* passwd) +{ + if(!verify) + { + if(passwd && len > strlen(reinterpret_cast(passwd))) + { + strcpy(buf,reinterpret_cast(passwd)); + return strlen(buf); + } + } + return 0; +} + +XSECCryptoKey* SecurityHelper::loadKeyFromFile(const char* pathname, const char* format, const char* password) +{ +#ifdef _DEBUG + NDC ndc("loadKeyFromFile"); +#endif + Category& log = Category::getInstance(XMLTOOLING_LOGCAT".SecurityHelper"); + log.info("loading private key from file (%s)", pathname); + + // Native objects. + PKCS12* p12=NULL; + EVP_PKEY* pkey=NULL; + + BIO* in=BIO_new(BIO_s_file_internal()); + if (in && BIO_read_filename(in, pathname)>0) { + // If the format isn't set, try and guess it. + if (!format) { + const int READSIZE = 1; + char buf[READSIZE]; + int mark; + + // Examine the first byte. + try { + if ((mark = BIO_tell(in)) < 0) + throw XMLSecurityException("Error loading key: BIO_tell() can't get the file position."); + if (BIO_read(in, buf, READSIZE) <= 0) + throw XMLSecurityException("Error loading key: BIO_read() can't read from the stream."); + if (BIO_seek(in, mark) < 0) + throw XMLSecurityException("Error loading key: BIO_seek() can't reset the file position."); + } + catch (exception&) { + log_openssl(); + BIO_free(in); + throw; + } + + // Check the first byte of the file. If it's some kind of DER-encoded structure + // (including PKCS12), it will begin with ASCII 048. Otherwise, assume it's PEM. + if (buf[0] != 48) { + format = "PEM"; + } + else { + // Here we know it's DER-encoded, now try to parse it as a PKCS12 ASN.1 structure. + // If it fails, must be another kind of DER-encoded structure. + if ((p12=d2i_PKCS12_bio(in, NULL)) == NULL) { + format = "DER"; + if (BIO_seek(in, mark) < 0) { + log_openssl(); + BIO_free(in); + throw XMLSecurityException("Error loading key: BIO_seek() can't reset the file position."); + } + } + else { + format = "PKCS12"; + } + } + log.debug("key encoding format for (%s) dynamically resolved as (%s)", pathname, format); + } + + // The format should be known, so parse accordingly. + if (!strcmp(format, "PEM")) { + pkey = PEM_read_bio_PrivateKey(in, NULL, passwd_callback, const_cast(password)); + } + else if (!strcmp(format, "DER")) { + pkey=d2i_PrivateKey_bio(in, NULL); + } + else if (!strcmp(format, "PKCS12")) { + if (!p12) + p12 = d2i_PKCS12_bio(in, NULL); + if (p12) { + X509* x=NULL; + PKCS12_parse(p12, const_cast(password), &pkey, &x, NULL); + PKCS12_free(p12); + X509_free(x); + } + } + else { + log.error("unknown key encoding format (%s)", format); + } + } + if (in) + BIO_free(in); + + // Now map it to an XSEC wrapper. + if (pkey) { + XSECCryptoKey* ret=NULL; + switch (pkey->type) { + case EVP_PKEY_RSA: + ret=new OpenSSLCryptoKeyRSA(pkey); + break; + + case EVP_PKEY_DSA: + ret=new OpenSSLCryptoKeyDSA(pkey); + break; + + default: + log.error("unsupported private key type"); + } + EVP_PKEY_free(pkey); + if (ret) + return ret; + } + + log_openssl(); + throw XMLSecurityException("Unable to load private key from file ($1).", params(1, pathname)); +} + +vector::size_type SecurityHelper::loadCertificatesFromFile( + vector& certs, const char* pathname, const char* format, const char* password + ) +{ +#ifdef _DEBUG + NDC ndc("loadCertificatesFromFile"); +#endif + Category& log = Category::getInstance(XMLTOOLING_LOGCAT".SecurityHelper"); + log.info("loading certificate(s) from file (%s)", pathname); + + vector::size_type count = certs.size(); + + // Native objects. + X509* x=NULL; + PKCS12* p12=NULL; + + BIO* in=BIO_new(BIO_s_file_internal()); + if (in && BIO_read_filename(in, pathname)>0) { + // If the format isn't set, try and guess it. + if (!format) { + const int READSIZE = 1; + char buf[READSIZE]; + int mark; + + // Examine the first byte. + try { + if ((mark = BIO_tell(in)) < 0) + throw XMLSecurityException("Error loading certificate: BIO_tell() can't get the file position."); + if (BIO_read(in, buf, READSIZE) <= 0) + throw XMLSecurityException("Error loading certificate: BIO_read() can't read from the stream."); + if (BIO_seek(in, mark) < 0) + throw XMLSecurityException("Error loading certificate: BIO_seek() can't reset the file position."); + } + catch (exception&) { + log_openssl(); + BIO_free(in); + throw; + } + + // Check the first byte of the file. If it's some kind of DER-encoded structure + // (including PKCS12), it will begin with ASCII 048. Otherwise, assume it's PEM. + if (buf[0] != 48) { + format = "PEM"; + } + else { + // Here we know it's DER-encoded, now try to parse it as a PKCS12 ASN.1 structure. + // If it fails, must be another kind of DER-encoded structure. + if ((p12=d2i_PKCS12_bio(in, NULL)) == NULL) { + format = "DER"; + if (BIO_seek(in, mark) < 0) { + log_openssl(); + BIO_free(in); + throw XMLSecurityException("Error loading certificate: BIO_seek() can't reset the file position."); + } + } + else { + format = "PKCS12"; + } + } + } + + // The format should be known, so parse accordingly. + if (!strcmp(format, "PEM")) { + while (x=PEM_read_bio_X509(in, NULL, NULL, NULL)) { + certs.push_back(new OpenSSLCryptoX509(x)); + X509_free(x); + } + } + else if (!strcmp(format, "DER")) { + x=d2i_X509_bio(in, NULL); + if (x) { + certs.push_back(new OpenSSLCryptoX509(x)); + X509_free(x); + } + } + else if (!strcmp(format, "PKCS12")) { + if (!p12) + p12 = d2i_PKCS12_bio(in, NULL); + if (p12) { + EVP_PKEY* pkey=NULL; + STACK_OF(X509)* CAstack = sk_X509_new_null(); + PKCS12_parse(p12, const_cast(password), &pkey, &x, &CAstack); + PKCS12_free(p12); + EVP_PKEY_free(pkey); + if (x) { + certs.push_back(new OpenSSLCryptoX509(x)); + X509_free(x); + } + x = sk_X509_pop(CAstack); + while (x) { + certs.push_back(new OpenSSLCryptoX509(x)); + X509_free(x); + x = sk_X509_pop(CAstack); + } + sk_X509_free(CAstack); + } + } + } + if (in) + BIO_free(in); + + if (certs.size() == count) { + log_openssl(); + throw XMLSecurityException("Unable to load certificate(s) from file ($1).", params(1, pathname)); + } + + return certs.size(); +} + +vector::size_type SecurityHelper::loadCRLsFromFile( + vector& crls, const char* pathname, const char* format + ) +{ +#ifdef _DEBUG + NDC ndc("loadCRLsFromFile"); +#endif + Category& log = Category::getInstance(XMLTOOLING_LOGCAT".SecurityHelper"); + log.info("loading CRL(s) from file (%s)", pathname); + + vector::size_type count = crls.size(); + + BIO* in=BIO_new(BIO_s_file_internal()); + if (in && BIO_read_filename(in, pathname)>0) { + // If the format isn't set, try and guess it. + if (!format) { + const int READSIZE = 1; + char buf[READSIZE]; + int mark; + + // Examine the first byte. + try { + if ((mark = BIO_tell(in)) < 0) + throw XMLSecurityException("Error loading CRL: BIO_tell() can't get the file position."); + if (BIO_read(in, buf, READSIZE) <= 0) + throw XMLSecurityException("Error loading CRL: BIO_read() can't read from the stream."); + if (BIO_seek(in, mark) < 0) + throw XMLSecurityException("Error loading CRL: BIO_seek() can't reset the file position."); + } + catch (exception&) { + log_openssl(); + BIO_free(in); + throw; + } + + // Check the first byte of the file. If it's some kind of DER-encoded structure + // it will begin with ASCII 048. Otherwise, assume it's PEM. + if (buf[0] != 48) { + format = "PEM"; + } + else { + format = "DER"; + } + log.debug("CRL encoding format for (%s) dynamically resolved as (%s)", pathname, format); + } + + X509_CRL* crl=NULL; + if (!strcmp(format, "PEM")) { + while (crl=PEM_read_bio_X509_CRL(in, NULL, NULL, NULL)) { + crls.push_back(new OpenSSLCryptoX509CRL(crl)); + X509_CRL_free(crl); + } + } + else if (!strcmp(format, "DER")) { + crl=d2i_X509_CRL_bio(in, NULL); + if (crl) { + crls.push_back(new OpenSSLCryptoX509CRL(crl)); + X509_CRL_free(crl); + } + } + else { + log.error("unknown CRL encoding format (%s)", format); + } + } + if (in) + BIO_free(in); + + if (crls.size() == count) { + log_openssl(); + throw XMLSecurityException("Unable to load CRL(s) from file ($1).", params(1, pathname)); + } + + return crls.size(); +} + +XSECCryptoKey* SecurityHelper::loadKeyFromURL(SOAPTransport& transport, const char* backing, const char* format, const char* password) +{ + // Fetch the data. + istringstream dummy; + transport.send(dummy); + istream& msg = transport.receive(); + + // Dump to output file. + ofstream out(backing, fstream::trunc|fstream::binary); + out << msg.rdbuf(); + + return loadKeyFromFile(backing, format, password); +} + +vector::size_type SecurityHelper::loadCertificatesFromURL( + vector& certs, SOAPTransport& transport, const char* backing, const char* format, const char* password + ) +{ + // Fetch the data. + istringstream dummy; + transport.send(dummy); + istream& msg = transport.receive(); + + // Dump to output file. + ofstream out(backing, fstream::trunc|fstream::binary); + out << msg.rdbuf(); + + return loadCertificatesFromFile(certs, backing, format, password); +} + +vector::size_type SecurityHelper::loadCRLsFromURL( + vector& crls, SOAPTransport& transport, const char* backing, const char* format + ) +{ + // Fetch the data. + istringstream dummy; + transport.send(dummy); + istream& msg = transport.receive(); + + // Dump to output file. + ofstream out(backing, fstream::trunc|fstream::binary); + out << msg.rdbuf(); + + return loadCRLsFromFile(crls, backing, format); +} + +bool SecurityHelper::matches(const XSECCryptoKey* key1, const XSECCryptoKey* key2) +{ + if (key1->getProviderName()!=DSIGConstants::s_unicodeStrPROVOpenSSL || + key2->getProviderName()!=DSIGConstants::s_unicodeStrPROVOpenSSL) { + Category::getInstance(XMLTOOLING_LOGCAT".SecurityHelper").warn("comparison of non-OpenSSL keys not supported"); + return false; + } + + // If one key is public or both, just compare the public key half. + if (key1->getKeyType()==XSECCryptoKey::KEY_RSA_PUBLIC || key1->getKeyType()==XSECCryptoKey::KEY_RSA_PAIR) { + if (key2->getKeyType()!=XSECCryptoKey::KEY_RSA_PUBLIC && key2->getKeyType()!=XSECCryptoKey::KEY_RSA_PAIR) + return false; + const RSA* rsa1 = static_cast(key1)->getOpenSSLRSA(); + const RSA* rsa2 = static_cast(key2)->getOpenSSLRSA(); + return (BN_cmp(rsa1->n,rsa2->n) == 0 && BN_cmp(rsa1->e,rsa2->e) == 0); + } + + // For a private key, compare the private half. + if (key1->getKeyType()==XSECCryptoKey::KEY_RSA_PRIVATE) { + if (key2->getKeyType()!=XSECCryptoKey::KEY_RSA_PRIVATE && key2->getKeyType()!=XSECCryptoKey::KEY_RSA_PAIR) + return false; + const RSA* rsa1 = static_cast(key1)->getOpenSSLRSA(); + const RSA* rsa2 = static_cast(key2)->getOpenSSLRSA(); + return (BN_cmp(rsa1->n,rsa2->n) == 0 && BN_cmp(rsa1->d,rsa2->d) == 0); + } + + // If one key is public or both, just compare the public key half. + if (key1->getKeyType()==XSECCryptoKey::KEY_DSA_PUBLIC || key1->getKeyType()==XSECCryptoKey::KEY_DSA_PAIR) { + if (key2->getKeyType()!=XSECCryptoKey::KEY_DSA_PUBLIC && key2->getKeyType()!=XSECCryptoKey::KEY_DSA_PAIR) + return false; + const DSA* dsa1 = static_cast(key1)->getOpenSSLDSA(); + const DSA* dsa2 = static_cast(key2)->getOpenSSLDSA(); + return (BN_cmp(dsa1->pub_key,dsa2->pub_key) == 0); + } + + // For a private key, compare the private half. + if (key1->getKeyType()==XSECCryptoKey::KEY_DSA_PRIVATE) { + if (key2->getKeyType()!=XSECCryptoKey::KEY_DSA_PRIVATE && key2->getKeyType()!=XSECCryptoKey::KEY_DSA_PAIR) + return false; + const DSA* dsa1 = static_cast(key1)->getOpenSSLDSA(); + const DSA* dsa2 = static_cast(key2)->getOpenSSLDSA(); + return (BN_cmp(dsa1->priv_key,dsa2->priv_key) == 0); + } + + Category::getInstance(XMLTOOLING_LOGCAT".SecurityHelper").warn("unsupported key type for comparison"); + return false; +} diff --git a/xmltoolingtest/Makefile.am b/xmltoolingtest/Makefile.am index 053f6ab..33e4392 100644 --- a/xmltoolingtest/Makefile.am +++ b/xmltoolingtest/Makefile.am @@ -23,6 +23,7 @@ xmlsec_sources = \ FilesystemCredentialResolverTest.h \ InlineKeyResolverTest.h \ MemoryStorageServiceTest.h \ + SecurityHelperTest.h \ SignatureTest.h else xmlsec_sources = diff --git a/xmltoolingtest/SecurityHelperTest.h b/xmltoolingtest/SecurityHelperTest.h new file mode 100644 index 0000000..aa80fbc --- /dev/null +++ b/xmltoolingtest/SecurityHelperTest.h @@ -0,0 +1,66 @@ +/* + * Copyright 2001-2007 Internet2 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "XMLObjectBaseTestCase.h" + +#include + +class SecurityHelperTest : public CxxTest::TestSuite { + vector certs; +public: + void setUp() { + } + + void tearDown() { + for_each(certs.begin(), certs.end(), xmltooling::cleanup()); + } + + void testKeysFromFiles() { + string pathname = data_path + "key.pem"; + auto_ptr key1(SecurityHelper::loadKeyFromFile(pathname.c_str())); + pathname = data_path + "key.der"; + auto_ptr key2(SecurityHelper::loadKeyFromFile(pathname.c_str())); + pathname = data_path + "test.pfx"; + auto_ptr key3(SecurityHelper::loadKeyFromFile(pathname.c_str(), NULL, "password")); + + TSM_ASSERT("PEM/DER keys did not match", SecurityHelper::matches(key1.get(), key2.get())); + TSM_ASSERT("DER/PKCS12 keys did not match", SecurityHelper::matches(key2.get(), key3.get())); + + pathname = data_path + "key2.pem"; + auto_ptr key4(SecurityHelper::loadKeyFromFile(pathname.c_str())); + TSM_ASSERT("Different keys matched", !SecurityHelper::matches(key3.get(), key4.get())); + } + + void testCertificatesFromFiles() { + string pathname = data_path + "cert.pem"; + SecurityHelper::loadCertificatesFromFile(certs, pathname.c_str()); + pathname = data_path + "cert.der"; + SecurityHelper::loadCertificatesFromFile(certs, pathname.c_str()); + pathname = data_path + "test.pfx"; + SecurityHelper::loadCertificatesFromFile(certs, pathname.c_str(), NULL, "password"); + + TSM_ASSERT_EQUALS("Wrong certificate count", certs.size(), 3); + + auto_ptr key1(certs[0]->clonePublicKey()); + auto_ptr key2(certs[0]->clonePublicKey()); + auto_ptr key3(certs[0]->clonePublicKey()); + + TSM_ASSERT("PEM/DER keys did not match", SecurityHelper::matches(key1.get(), key2.get())); + TSM_ASSERT("DER/PKCS12 keys did not match", SecurityHelper::matches(key2.get(), key3.get())); + + for_each(certs.begin(), certs.end(), xmltooling::cleanup()); + } +}; diff --git a/xmltoolingtest/data/cert.der b/xmltoolingtest/data/cert.der new file mode 100644 index 0000000000000000000000000000000000000000..2776767ae2180e3c0b74d08ddbd99501e17f4d5d GIT binary patch literal 659 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