| 1: | <?php |
| 2: | /** |
| 3: | * XMLDSigToken.php |
| 4: | * |
| 5: | * Copyright © 2017-2026 - Thierry Thiers <webcoder31@gmail.com> |
| 6: | * |
| 7: | * This software is governed by the CeCILL-C license under French law and |
| 8: | * abiding by the rules of distribution of free software. You can use, modify |
| 9: | * and/or redistribute the software under the terms of the CeCILL-C license as |
| 10: | * circulated by CEA, CNRS and INRIA at the following URL: |
| 11: | * |
| 12: | * http://www.cecill.info |
| 13: | * |
| 14: | * As a counterpart to the access to the source code and rights to copy, modify |
| 15: | * and redistribute granted by the license, users are provided only with a |
| 16: | * limited warranty and the software's author, the holder of the economic |
| 17: | * rights, and the successive licensors have only limited liability. |
| 18: | * |
| 19: | * In this respect, the user's attention is drawn to the risks associated with |
| 20: | * loading, using, modifying and/or developing or reproducing the software by |
| 21: | * the user in light of its specific status of free software, that may mean that |
| 22: | * it is complicated to manipulate, and that also therefore means that it is |
| 23: | * reserved for developers and experienced professionals having in-depth |
| 24: | * computer knowledge. Users are therefore encouraged to load and test the |
| 25: | * software's suitability as regards their requirements in conditions enabling |
| 26: | * the security of their systems and/or data to be ensured and, more generally, |
| 27: | * to use and operate it in the same conditions as regards security. |
| 28: | * |
| 29: | * The fact that you are presently reading this means that you have had |
| 30: | * knowledge of the CeCILL-C license and that you accept its terms. |
| 31: | * |
| 32: | * @author Thierry Thiers <webcoder31@gmail.com> |
| 33: | * @copyright 2017-2026 - Thierry Thiers <webcoder31@gmail.com> |
| 34: | * @license http://www.cecill.info CeCILL-C License |
| 35: | * @version 1.1.0 |
| 36: | */ |
| 37: | |
| 38: | |
| 39: | // Namespace. |
| 40: | namespace webcoder31\ezxmldsig; |
| 41: | |
| 42: | // Use. |
| 43: | use DateTime; |
| 44: | use DateTimeZone; |
| 45: | use DOMDocument; |
| 46: | use DOMElement; |
| 47: | use DOMNode; |
| 48: | use DOMXPath; |
| 49: | use Exception; |
| 50: | use RobRichards\XMLSecLibs\XMLSecurityDSig; |
| 51: | use RobRichards\XMLSecLibs\XMLSecEnc; |
| 52: | use RobRichards\XMLSecLibs\XMLSecurityKey; |
| 53: | |
| 54: | /** |
| 55: | * Build or analyze an XML token. |
| 56: | * |
| 57: | * An XML token is an enveloping XML Digital Signature (cf. RFC 3275) |
| 58: | * containing signed and timestamped user data. These data may also be |
| 59: | * encrypted. In this case we talk about Secure XML token. |
| 60: | * |
| 61: | * **This class uses the following default algorithms to operate:** |
| 62: | * |
| 63: | * - Algorithm used to canonicalize data before signing is: |
| 64: | * http://www.w3.org/TR/2001/REC-xml-c14n-20010315. |
| 65: | * |
| 66: | * - Algorithm used to generate the signature is: |
| 67: | * http://www.w3.org/2001/04/xmldsig-more#rsa-sha256. |
| 68: | * |
| 69: | * - Algorithm used to compute the hash of token data is: |
| 70: | * http://www.w3.org/2001/04/xmlenc#sha256. |
| 71: | * |
| 72: | * - Algorithm used to encode data's values is: Base64. |
| 73: | * |
| 74: | * The analysis expects these algorithms unless its options name others (see |
| 75: | * `__construct()`), and it requires a trust anchor: the CA certificates |
| 76: | * and/or the pinned certificate the signing certificate is verified against. |
| 77: | * Without it, no token is valid. |
| 78: | * |
| 79: | * **An XML token looks like this:** |
| 80: | * |
| 81: | * ```xml |
| 82: | * <?xml version="1.0"?> |
| 83: | * <ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#"> |
| 84: | * <ds:SignedInfo> |
| 85: | * <ds:CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"/> |
| 86: | * <ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/> |
| 87: | * <ds:Reference URI="#Token"> |
| 88: | * <ds:Transforms> |
| 89: | * <ds:Transform Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"/> |
| 90: | * </ds:Transforms> |
| 91: | * <ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/> |
| 92: | * <ds:DigestValue>---- TOKEN HASH ----</ds:DigestValue> |
| 93: | * </ds:Reference> |
| 94: | * </ds:SignedInfo> |
| 95: | * <ds:SignatureValue>---- SIGNATURE VALUE ----</ds:SignatureValue> |
| 96: | * <ds:KeyInfo> |
| 97: | * <ds:X509Data> |
| 98: | * <ds:X509Certificate>---- X.509 CERTIFICATE ----</ds:X509Certificate> |
| 99: | * </ds:X509Data> |
| 100: | * </ds:KeyInfo> |
| 101: | * <ds:Object Id="Token"> |
| 102: | * <Token> |
| 103: | * <TokenTimestamp>2026-10-17T04:43:05Z</TokenTimestamp> |
| 104: | * <TokenId>---- 128-BIT RANDOM IDENTIFIER (HEXADECIMAL) ----</TokenId> |
| 105: | * <TokenData> |
| 106: | * <data1 Algorithm="base64">---- BASE64 ENCODED DATA ----</data1> |
| 107: | * <data2 Algorithm="base64">---- BASE64 ENCODED DATA ----</data2> |
| 108: | * <data3> |
| 109: | * <data31 Algorithm="base64">---- BASE64 ENCODED DATA ----</data31> |
| 110: | * <data32 Algorithm="base64">---- BASE64 ENCODED DATA ----</data32> |
| 111: | * </data3> |
| 112: | * <data4 Algorithm="base64">---- BASE64 ENCODED DATA ----</data4> |
| 113: | * </TokenData> |
| 114: | * </Token> |
| 115: | * </ds:Object> |
| 116: | * </ds:Signature> |
| 117: | * ``` |
| 118: | * |
| 119: | * **The signing process consists of the following actions:** |
| 120: | * |
| 121: | * - Load data from an associative array. |
| 122: | * |
| 123: | * - Encode data values using Base64. |
| 124: | * |
| 125: | * - Build a node from the encoded array of data (cf. node `<TokenData />`). |
| 126: | * |
| 127: | * - Compute an UTC timestamp and store it in a node `<TokenTimestamp />`. |
| 128: | * |
| 129: | * - Generate a random identifier of 128 bits (`random_bytes()`) and store |
| 130: | * it, in hexadecimal, in a node `<TokenId />`. Signed with the rest of the |
| 131: | * token, it lets the receiver refuse a token it has already accepted (see |
| 132: | * `getTokenId()`). |
| 133: | * |
| 134: | * - Aggregate the nodes `<TokenTimestamp />`, `<TokenId />` and |
| 135: | * `<TokenData />` inside a node `<Token />`. |
| 136: | * |
| 137: | * - Load the base DOM document representing an XML digital signature: |
| 138: | * |
| 139: | * ```xml |
| 140: | * <ds:Signature xmlns="http://www.w3.org/2000/09/xmldsig#"> |
| 141: | * <ds:SignedInfo> |
| 142: | * <ds:SignatureMethod /> |
| 143: | * </ds:SignedInfo> |
| 144: | * </ds:Signature> |
| 145: | * ``` |
| 146: | * |
| 147: | * - Create a node `<ds:Object Id="Token" />` and append it to the node |
| 148: | * `<ds:Signature />`. |
| 149: | * |
| 150: | * - Add the node `<Token />` to the node `<ds:Object Id="Token" />`. |
| 151: | * |
| 152: | * - NOTE: If data encryption is requested, this process will take place here |
| 153: | * (this will be described later). |
| 154: | * |
| 155: | * - Build a node `<ds:Reference URI="#Token" />` and append it to the node |
| 156: | * `<ds:SignedInfo />`. This consists of: |
| 157: | * |
| 158: | * - Canonicalizing the content of the node `<ds:Object Id="Token" />`. |
| 159: | * |
| 160: | * - Computing the hash (SHA-256 by default) of the canonicalized node and |
| 161: | * storing it in the node `<ds:DigestValue />`. |
| 162: | * |
| 163: | * - Include, in the XML token, the X.509 certificate associated to the |
| 164: | * private key that will be used to perform the signing (see the next steps |
| 165: | * below). This certificate will be used to perform the signature validation |
| 166: | * process, avoiding the need, for the receiver of the XML Digital Signature, |
| 167: | * to own it on its side. |
| 168: | * |
| 169: | * - Canonicalize the content of the node `<ds:SignedInfo />`. |
| 170: | * |
| 171: | * - Compute the signature of the canonicalized node using the private key |
| 172: | * dedicated to perform signing. |
| 173: | * |
| 174: | * - Store the result in the node `<ds:SignatureValue />`. |
| 175: | * |
| 176: | * - Save the DOM document in XML format. |
| 177: | * |
| 178: | * **The signature validation process consists of the following actions:** |
| 179: | * |
| 180: | * - Load the XML Digital Signature in a DOM document. A document with a |
| 181: | * document type declaration (DOCTYPE), or not encoded in UTF-8, is |
| 182: | * refused before it is parsed, and the parser never accesses the network |
| 183: | * (see `_loadXML()`). |
| 184: | * |
| 185: | * - Verify that the algorithms of the XML Digital Signature are the |
| 186: | * expected ones. |
| 187: | * |
| 188: | * - Compute the hash of the content of the node `<ds:Object Id="Token" />` |
| 189: | * with the digest algorithm. |
| 190: | * |
| 191: | * - Compare the computed hash with the one stored in the node |
| 192: | * `<ds:DigestValue />`. If the hashes are different, this means that the |
| 193: | * content of the node `<ds:Object Id="Token" />` has been altered. |
| 194: | * |
| 195: | * - Canonicalize the content of the node `<ds:SignedInfo />`. |
| 196: | * |
| 197: | * - Compute the signature of the node `<ds:SignedInfo />` using the X.509 |
| 198: | * certificate included in the XML token. |
| 199: | * |
| 200: | * - Compare the computed signature with the one stored in the node |
| 201: | * `<ds:SignatureValue>`. If the signatures are different, this means that |
| 202: | * the content of the node `<ds:SignedInfo />` has not been signed using the |
| 203: | * private key associated to the X.509 certificate included in the XML |
| 204: | * Digital Signature. |
| 205: | * |
| 206: | * - Verify that the X.509 certificate included in the XML Digital Signature |
| 207: | * is trusted, against the trust anchor given to the analysis: CA |
| 208: | * certificates up to which OpenSSL verifies the chain of the certificate, |
| 209: | * and/or the pinned certificate the token must be signed with. Without a |
| 210: | * trust anchor, no token is valid: anyone can sign a token with a |
| 211: | * certificate of their own. |
| 212: | * |
| 213: | * - NOTE: If data decryption is required, this process will take place here |
| 214: | * (this will be described later). |
| 215: | * |
| 216: | * - Extract the timestamp and the identifier stored in the content of the |
| 217: | * node `<ds:Object Id="Token" />` and memorise them. A token without |
| 218: | * identifier is rejected. |
| 219: | * |
| 220: | * - Extract the data stored in the content of the node |
| 221: | * `<ds:Object Id="Token" />`. |
| 222: | * |
| 223: | * - Decode data values using Base64. |
| 224: | * |
| 225: | * - Build an associative array containing the decoded data. |
| 226: | * |
| 227: | * **Token data encryption / decryption:** |
| 228: | * |
| 229: | * The token data may also be encrypted before signing. In this case the class |
| 230: | * will use the following default algorithms to perform encryption and |
| 231: | * decryption of data: |
| 232: | * |
| 233: | * - Algorithm used to encrypt / decrypt data is: |
| 234: | * http://www.w3.org/2009/xmlenc11#aes256-gcm (authenticated symmetric |
| 235: | * ciphering). |
| 236: | * |
| 237: | * - Algorithm used to encrypt / decrypt the session key is: |
| 238: | * http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p (asymmetric ciphering). |
| 239: | * |
| 240: | * **A secure (encrypted) XML token looks like this:** |
| 241: | * |
| 242: | * ```xml |
| 243: | * <?xml version="1.0"?> |
| 244: | * <ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#"> |
| 245: | * <ds:SignedInfo> |
| 246: | * <ds:CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"/> |
| 247: | * <ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/> |
| 248: | * <ds:Reference URI="#Token"> |
| 249: | * <ds:Transforms> |
| 250: | * <ds:Transform Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"/> |
| 251: | * </ds:Transforms> |
| 252: | * <ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/> |
| 253: | * <ds:DigestValue>---- TOKEN HASH ----</ds:DigestValue> |
| 254: | * </ds:Reference> |
| 255: | * </ds:SignedInfo> |
| 256: | * <ds:SignatureValue>---- SIGNATURE VALUE ----</ds:SignatureValue> |
| 257: | * <ds:KeyInfo> |
| 258: | * <ds:X509Data> |
| 259: | * <ds:X509Certificate>---- X.509 CERTIFICATE ----</ds:X509Certificate> |
| 260: | * </ds:X509Data> |
| 261: | * </ds:KeyInfo> |
| 262: | * <ds:Object Id="Token"> |
| 263: | * <xenc:EncryptedData xmlns:xenc="http://www.w3.org/2001/04/xmlenc#" Type="http://www.w3.org/2001/04/xmlenc#Content"> |
| 264: | * <xenc:EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes256-gcm"/> |
| 265: | * <ds:KeyInfo xmlns:dsig="http://www.w3.org/2000/09/xmldsig#"> |
| 266: | * <xenc:EncryptedKey> |
| 267: | * <xenc:EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p"/> |
| 268: | * <xenc:CipherData> |
| 269: | * <xenc:CipherValue>---- CIPHERED SESSION KEY ----</xenc:CipherValue> |
| 270: | * </xenc:CipherData> |
| 271: | * </xenc:EncryptedKey> |
| 272: | * </ds:KeyInfo> |
| 273: | * <xenc:CipherData> |
| 274: | * <xenc:CipherValue>---- ENCRYPTED DATA ----</xenc:CipherValue> |
| 275: | * </xenc:CipherData> |
| 276: | * </xenc:EncryptedData> |
| 277: | * </ds:Object> |
| 278: | * </ds:Signature> |
| 279: | * ``` |
| 280: | * |
| 281: | * **The encryption process is the following:** |
| 282: | * |
| 283: | * - Randomly generate a session key. |
| 284: | * |
| 285: | * - Cipher the session key using the X.509 certificate dedicated to the |
| 286: | * encryption process, in order to transmit it safely in the XML Digital |
| 287: | * Signature. |
| 288: | * |
| 289: | * - Store the ciphered session key in the node `<xenc:CipherValue />` which is |
| 290: | * located inside the node `<xenc:EncryptedKey />`. |
| 291: | * |
| 292: | * - Encrypt the content of the node `<ds:Object Id="Token" />` using the |
| 293: | * non-ciphered session key and store the result in the last node |
| 294: | * `<xenc:CipherValue />`. |
| 295: | * |
| 296: | * **The decryption process is the following:** |
| 297: | * |
| 298: | * - Retrieve the node `<xenc:EncryptedData />`. |
| 299: | * |
| 300: | * - Decipher the ciphered session key stored in the node `<xenc:CipherValue />` |
| 301: | * (the one which is located inside the node `<xenc:EncryptedKey />`) using |
| 302: | * the private key associated to the X.509 certificate that has been used to |
| 303: | * perform the encryption. |
| 304: | * |
| 305: | * - Decrypt the content of the last node `<xenc:CipherValue />` using the |
| 306: | * deciphered session key. |
| 307: | * |
| 308: | * The decryption only takes place once the digest, the signature, the trust |
| 309: | * in the signing certificate and the algorithms have been verified, and |
| 310: | * every failure that depends on the encrypted content gives the same error, |
| 311: | * `Token decryption failed!`. RSA 1.5 is refused, and a CBC algorithm is |
| 312: | * only accepted when the analysis declares it (see `__construct()`). |
| 313: | * |
| 314: | * **Replay:** |
| 315: | * |
| 316: | * A valid token can be submitted again for as long as `isOutOfDate()` |
| 317: | * accepts it. The class keeps no list of the tokens it has analyzed: the |
| 318: | * caller must keep the identifiers it accepted (`getTokenId()`) and refuse a |
| 319: | * token whose identifier is among them. |
| 320: | */ |
| 321: | class XMLDSigToken |
| 322: | { |
| 323: | /* CONSTANTS */ |
| 324: | |
| 325: | /** @const string The default XMLDSIG namespace prefix used in the XML digital |
| 326: | signature. */ |
| 327: | const XMLDSIG_NS_PREFIX = 'ds'; |
| 328: | |
| 329: | /** @const string The name of the token node. */ |
| 330: | const TOKEN_NAME = 'Token'; |
| 331: | |
| 332: | /** @const string The name of the token data node. */ |
| 333: | const TOKEN_DATA_NAME = 'TokenData'; |
| 334: | |
| 335: | /** @const string The name of the token timestamp node. */ |
| 336: | const TOKEN_TIMESTAMP_NAME = 'TokenTimestamp'; |
| 337: | |
| 338: | /** @const string The name of the token identifier node. */ |
| 339: | const TOKEN_ID_NAME = 'TokenId'; |
| 340: | |
| 341: | /** @const int The number of random bytes of the token identifier, written |
| 342: | in hexadecimal (see getTokenId()). */ |
| 343: | const TOKEN_ID_BYTES = 16; |
| 344: | |
| 345: | /** @const string The name of the node that holds one audience of the |
| 346: | token (see getAudience()). A token holds one such node per audience. */ |
| 347: | const TOKEN_AUDIENCE_NAME = 'TokenAudience'; |
| 348: | |
| 349: | /** @const string The name of the token expiry node (see getExpiry()). */ |
| 350: | const TOKEN_EXPIRY_NAME = 'TokenExpiry'; |
| 351: | |
| 352: | /** @const int The latest expiry the format of the timestamp can write |
| 353: | (9999-12-31T23:59:59Z), as a UNIX timestamp. */ |
| 354: | private const MAX_EXPIRY_TIME = 253402300799; |
| 355: | |
| 356: | /** @const string The format of the token timestamp (YYYY-MM-DDThh:mm:ssZ). |
| 357: | NOTE: The timestamp should always be expressed in UTC. */ |
| 358: | const TOKEN_TIMESTAMP_FORMAT = 'Y-m-d\TH:i:s\Z'; |
| 359: | |
| 360: | /** @const int The default time to live (in seconds) used to check token |
| 361: | peremption. */ |
| 362: | const TOKEN_DEFAULT_TTL = 60; |
| 363: | |
| 364: | /** @const int The synchronization offset (in seconds) allowed for verifying |
| 365: | if token is out of date. */ |
| 366: | const DESYNC_TOLERANCE = 30; |
| 367: | |
| 368: | |
| 369: | const C14N = XMLSecurityDSig::C14N; |
| 370: | const C14N_COMMENTS = XMLSecurityDSig::C14N_COMMENTS; |
| 371: | const EXC_C14N = XMLSecurityDSig::EXC_C14N; |
| 372: | const EXC_C14N_COMMENTS = XMLSecurityDSig::EXC_C14N_COMMENTS; |
| 373: | |
| 374: | const SHA1 = XMLSecurityDSig::SHA1; |
| 375: | const SHA256 = XMLSecurityDSig::SHA256; |
| 376: | const SHA384 = XMLSecurityDSig::SHA384; |
| 377: | const SHA512 = XMLSecurityDSig::SHA512; |
| 378: | const RIPEMD160 = XMLSecurityDSig::RIPEMD160; |
| 379: | |
| 380: | const TRIPLEDES_CBC = XMLSecurityKey::TRIPLEDES_CBC; |
| 381: | const AES128_CBC = XMLSecurityKey::AES128_CBC; |
| 382: | const AES192_CBC = XMLSecurityKey::AES192_CBC; |
| 383: | const AES256_CBC = XMLSecurityKey::AES256_CBC; |
| 384: | const AES128_GCM = XMLSecurityKey::AES128_GCM; |
| 385: | const AES192_GCM = XMLSecurityKey::AES192_GCM; |
| 386: | const AES256_GCM = XMLSecurityKey::AES256_GCM; |
| 387: | const RSA_OAEP_MGF1P = XMLSecurityKey::RSA_OAEP_MGF1P; |
| 388: | const DSA_SHA1 = XMLSecurityKey::DSA_SHA1; |
| 389: | const RSA_SHA1 = XMLSecurityKey::RSA_SHA1; |
| 390: | const RSA_SHA256 = XMLSecurityKey::RSA_SHA256; |
| 391: | const RSA_SHA384 = XMLSecurityKey::RSA_SHA384; |
| 392: | const RSA_SHA512 = XMLSecurityKey::RSA_SHA512; |
| 393: | const HMAC_SHA1 = XMLSecurityKey::HMAC_SHA1; |
| 394: | |
| 395: | /** @const array The context each option applies to (see |
| 396: | _checkOptions()). */ |
| 397: | private const OPTION_CONTEXTS = [ |
| 398: | 'base64Encode' => 'creation', |
| 399: | 'xmldsigNsPrefix' => 'creation', |
| 400: | 'audience' => 'creation', |
| 401: | 'validity' => 'creation', |
| 402: | 'expectedAudience' => 'analysis', |
| 403: | 'canonicalizationAlgorithm' => 'any', |
| 404: | 'signatureAlgorithm' => 'any', |
| 405: | 'digestAlgorithm' => 'any', |
| 406: | 'checkSigningAlgorithms' => 'analysis', |
| 407: | 'caCertPath' => 'analysis', |
| 408: | 'signerCertPath' => 'analysis', |
| 409: | 'expectedSubject' => 'analysis', |
| 410: | 'sessionKeyCipheringAlgorithm' => 'encryption', |
| 411: | 'cryptAlgorithm' => 'encryption', |
| 412: | 'checkCryptingAlgorithms' => 'decryption', |
| 413: | ]; |
| 414: | |
| 415: | /** @const string The error of every failure that depends on the |
| 416: | decrypted content (see _decryptToken()). */ |
| 417: | private const DECRYPTION_FAILED = 'Token decryption failed!'; |
| 418: | |
| 419: | |
| 420: | /* PROPERTIES */ |
| 421: | |
| 422: | /** @var string The XML namespace (xmlns) prefix to use in the XML digital |
| 423: | signature tags. */ |
| 424: | private $xmldsigNsPrefix = self::XMLDSIG_NS_PREFIX; |
| 425: | |
| 426: | /** @var string The algorithm that should be used to canonicalize the node |
| 427: | "ds:Signature/ds:SignedInfo" of the XML digital signature before signing |
| 428: | it. */ |
| 429: | private $canonicalizationAlgorithm = self::C14N; |
| 430: | |
| 431: | /** @var string The algorithm that should be used to sign the computed hash |
| 432: | of the token. */ |
| 433: | private $signatureAlgorithm = self::RSA_SHA256; |
| 434: | |
| 435: | /** @var string The algorithm that should be used to compute the hash of the |
| 436: | token. */ |
| 437: | private $digestAlgorithm = self::SHA256; |
| 438: | |
| 439: | /** @var string The algorithm that should be used to canonicalize the token |
| 440: | before computing its hash. NOTE: In our case, it is the same algorithm as |
| 441: | the one used to canonicalize the node "ds:Signature/ds:signedInfo" |
| 442: | (see: $canonicalizationAlgorithm property). */ |
| 443: | private $transformAlgorithm = self::C14N; |
| 444: | |
| 445: | /** @var boolean Whether to check the algorithms actually used (read from |
| 446: | the XML digital signature) to sign the token or not. */ |
| 447: | private $checkSigningAlgorithms = true; |
| 448: | |
| 449: | /** @var string The algorithm that should be used to generate the session |
| 450: | key which will be used to encrypt the token. */ |
| 451: | private $sessionKeyCipheringAlgorithm = self::AES256_GCM; |
| 452: | |
| 453: | /** @var string The algorithm that should be used to encrypt the token with |
| 454: | the generated session key. */ |
| 455: | private $cryptAlgorithm = self::RSA_OAEP_MGF1P; |
| 456: | |
| 457: | /** @var boolean Whether to check the algorithms actually used (read from |
| 458: | the XML digital signature) to encrypt the token or not. */ |
| 459: | private $checkCryptingAlgorithms = true; |
| 460: | |
| 461: | /** @var DOMXPath The DOMXPath object used to navigate through the XML |
| 462: | digital signature enveloping the token. */ |
| 463: | private $xpath = null; |
| 464: | |
| 465: | /** @var string The content of the private key used to sign the token |
| 466: | data. */ |
| 467: | private $signKey = null; |
| 468: | |
| 469: | /** @var string The password to access the private key used to sign the |
| 470: | token data. */ |
| 471: | private $signKeyPassword = null; |
| 472: | |
| 473: | /** @var string The content of the X.509 certificate associated to the |
| 474: | private key (cf. $signKey) used to sign the token. This certificate is |
| 475: | included in the XML token, in order to verify that the XML |
| 476: | Digital Signature is valid. */ |
| 477: | private $signCert = null; |
| 478: | |
| 479: | /** @var string The content of the private key used to decrypt the token. */ |
| 480: | private $cryptKey = null; |
| 481: | |
| 482: | /** @var string The password to access the private key used to decrypt the |
| 483: | token. */ |
| 484: | private $cryptKeyPassword = null; |
| 485: | |
| 486: | /** @var string The content of the X.509 certificate associated to the |
| 487: | private key used to encrypt the session key used to encrypt the token. */ |
| 488: | private $cryptCert = null; |
| 489: | |
| 490: | /** @var string The XML digital signature enveloping the token. */ |
| 491: | private $xml = null; |
| 492: | |
| 493: | /** @var array The token data (a flat or multidimensional associative |
| 494: | array). */ |
| 495: | private $data = null; |
| 496: | |
| 497: | /** @var boolean Flag that indicates whether the token data should be base64 |
| 498: | encoded when building the XML token or not. */ |
| 499: | private $base64EncodeData = true; |
| 500: | |
| 501: | /** @var string The token timestamp (format: 'yyyy-MM-ddThh:mm:ssZ'). */ |
| 502: | private $timestamp = null; |
| 503: | |
| 504: | /** @var string The token identifier (see getTokenId()). */ |
| 505: | private $tokenId = null; |
| 506: | |
| 507: | /** @var array|null The audiences of the token to create (option |
| 508: | "audience"). */ |
| 509: | private $audienceToWrite = null; |
| 510: | |
| 511: | /** @var array|null The audiences read from the token (see |
| 512: | getAudience()). */ |
| 513: | private $audience = null; |
| 514: | |
| 515: | /** @var int|null The validity of the token to create, in seconds (option |
| 516: | "validity"). */ |
| 517: | private $validity = null; |
| 518: | |
| 519: | /** @var string|null The token expiry (format: 'yyyy-MM-ddThh:mm:ssZ', see |
| 520: | getExpiry()). */ |
| 521: | private $expiry = null; |
| 522: | |
| 523: | /** @var boolean Whether the analyzed token has a node "TokenExpiry", |
| 524: | well formed or not (see isOutOfDate()). */ |
| 525: | private $hasExpiryNode = false; |
| 526: | |
| 527: | /** @var string|null The audience the analyzed token must be addressed to |
| 528: | (option "expectedAudience"). */ |
| 529: | private $expectedAudience = null; |
| 530: | |
| 531: | /** @var X509Cert The X.509 certificate included in the XML Digital |
| 532: | Signature. */ |
| 533: | private $x509Certificate = null; |
| 534: | |
| 535: | /** @var array|null The paths of the PEM files holding the trusted CA |
| 536: | certificates (option "caCertPath"). */ |
| 537: | private $trustedCACertPaths = null; |
| 538: | |
| 539: | /** @var string|null The pinned signing certificate, in PEM format (option |
| 540: | "signerCertPath"), or, when the object creates a token, the certificate |
| 541: | of its own signing key. */ |
| 542: | private $pinnedSignerCert = null; |
| 543: | |
| 544: | /** @var array|string|null The subject expected for the signing |
| 545: | certificate (option "expectedSubject"). */ |
| 546: | private $expectedSubject = null; |
| 547: | |
| 548: | /** @var boolean Flag that indicates whether the X.509 certificate included |
| 549: | in the XML Digital Signature is trusted or not. */ |
| 550: | private $isCertTrusted = null; |
| 551: | |
| 552: | /** @var boolean Flag that indicates whether the token is encrypted or |
| 553: | not. */ |
| 554: | private $isDataEncrypted = null; |
| 555: | |
| 556: | /** @var boolean Flag that indicates whether the token hash is valid or |
| 557: | not. */ |
| 558: | private $isDigestValueOk = null; |
| 559: | |
| 560: | /** @var boolean Flag that indicates whether the token hash signature is |
| 561: | valid or not. */ |
| 562: | private $isSignatureValueOk = null; |
| 563: | |
| 564: | /** @var string Error encountered during the analysis of |
| 565: | the XML digital signature enveloping the token. */ |
| 566: | private $error = null; |
| 567: | |
| 568: | /** @var array List of the anomalies encountered during the analysis |
| 569: | of the XML digital signature enveloping the token. */ |
| 570: | private $anomalies = array(); |
| 571: | |
| 572: | /** |
| 573: | * Defines a custom output for `print_r()` and `var_dump()` functions, |
| 574: | * in order to hide cryptographic material used by the object for |
| 575: | * security reasons. |
| 576: | */ |
| 577: | public function __debugInfo() |
| 578: | { |
| 579: | return [ |
| 580: | 'isValid' => $this->isSignatureValid(), |
| 581: | 'isDigestValueOk' => $this->isDigestValueOk, |
| 582: | 'isSignatureValueOk' => $this->isSignatureValueOk, |
| 583: | 'isCertTrusted' => $this->isCertTrusted, |
| 584: | 'isDataEncrypted' => $this->isDataEncrypted, |
| 585: | 'timestamp' => $this->timestamp, |
| 586: | 'tokenId' => $this->getTokenId(), |
| 587: | 'audience' => $this->getAudience(), |
| 588: | 'expiry' => $this->getExpiry(), |
| 589: | 'data' => $this->data, |
| 590: | 'error' => $this->error, |
| 591: | 'anomalies' => $this->anomalies |
| 592: | ]; |
| 593: | } |
| 594: | |
| 595: | |
| 596: | /** |
| 597: | * Instantiate an XMLDSigToken object. |
| 598: | * |
| 599: | * This constructor is `protected`. To get an instance of an XMLDSigToken |
| 600: | * object use one of the following `static` functions according to what the |
| 601: | * object is intended for: |
| 602: | * |
| 603: | * - `createXMLToken()` |
| 604: | * |
| 605: | * - `createSecureXMLToken()` |
| 606: | * |
| 607: | * - `analyzeXMLToken()` |
| 608: | * |
| 609: | * - `analyzeSecureXMLToken()` |
| 610: | * |
| 611: | * An XMLDSigToken object allows creating an XML token and also analyzing an |
| 612: | * existing XML token. The type of the `$xmlOrData` parameter determines how |
| 613: | * the object behaves. |
| 614: | * |
| 615: | * **XML token creation:** |
| 616: | * |
| 617: | * If `$xmlOrData` is an array (a flat or multidimensional associative |
| 618: | * array), the object will create an enveloping XML digital signature |
| 619: | * containing an XML token that holds the provided data and a timestamp |
| 620: | * generated automatically, with a random identifier (see `getTokenId()`). |
| 621: | * In this case, the `$signKeyPath` and `$signCertPath` parameters are |
| 622: | * required. If the `$cryptCertPath` parameter is also provided, the XML |
| 623: | * token will be encrypted for the owner of this certificate. The |
| 624: | * `$cryptKeyPath` parameter, the private key of the recipient, is |
| 625: | * optional: given, it is used to decrypt the created token and check its |
| 626: | * content too. If this private key is protected by a password, this |
| 627: | * password may be passed using the `$cryptKeyPassword` parameter. |
| 628: | * |
| 629: | * **XML token analysis:** |
| 630: | * |
| 631: | * If `$xmlOrData` is a string, the object will treat it as an enveloping |
| 632: | * XML digital signature containing a token: |
| 633: | * |
| 634: | * - It will check that the signature is valid. |
| 635: | * |
| 636: | * - It will decrypt the token if this one is encrypted. |
| 637: | * |
| 638: | * - It will extract timestamp and data from the token. |
| 639: | * |
| 640: | * In this case, the `$xmlOrData` parameter and a trust anchor (the |
| 641: | * options `caCertPath` and/or `signerCertPath`, see below) are required: |
| 642: | * without a trust anchor, no token is valid. If the XML |
| 643: | * token is encrypted, the `$cryptKeyPath` parameter must also be provided. |
| 644: | * If the private key used for decryption (cf. `$cryptKeyPath` parameter) is |
| 645: | * protected by a password, this password may be passed using the |
| 646: | * `$cryptKeyPassword` parameter. |
| 647: | * |
| 648: | * When the object creates a token, it analyzes it right away to check it, |
| 649: | * and trusts the certificate of its own signing key (`$signCertPath`) for |
| 650: | * that: the trust options do not apply to token creation. An encrypted |
| 651: | * token created without `$cryptKeyPath` is checked up to its decryption |
| 652: | * (structure, algorithms, digest, signature, trust in the signing |
| 653: | * certificate) and its content is not read (see `createSecureXMLToken()`). |
| 654: | * |
| 655: | * **Configuring the object:** |
| 656: | * |
| 657: | * The `$options` parameter of this function allows to override the default |
| 658: | * configuration of the object passing it the desired options via an |
| 659: | * associative array. |
| 660: | * |
| 661: | * Example: |
| 662: | * |
| 663: | * ```php |
| 664: | * $options = [ |
| 665: | * 'base64Encode' => false, |
| 666: | * 'xmldsigNsPrefix' => '' |
| 667: | * ]; |
| 668: | * ``` |
| 669: | * |
| 670: | * Each option applies to a context: token creation (`createXMLToken()`, |
| 671: | * `createSecureXMLToken()`), token analysis (`analyzeXMLToken()`, |
| 672: | * `analyzeSecureXMLToken()`), or both. The encryption options only apply |
| 673: | * when an encryption certificate (creation) or a decryption key |
| 674: | * (analysis) is given. An unknown option, an option given out of its |
| 675: | * context, or an option whose value is NULL or invalid throws an |
| 676: | * exception. |
| 677: | * |
| 678: | * The algorithm options apply to both contexts: at creation, they are the |
| 679: | * algorithms the token is built with; at analysis, the algorithms the |
| 680: | * token must have been built with. A token built with other algorithms is |
| 681: | * rejected (`getError()` lists them), unless `checkSigningAlgorithms` or |
| 682: | * `checkCryptingAlgorithms` is FALSE. To analyze a token built with |
| 683: | * RSA-SHA1 and SHA-1: |
| 684: | * |
| 685: | * ```php |
| 686: | * $options = [ |
| 687: | * 'caCertPath' => ['intermediate.cert.pem', 'root.cert.pem'], |
| 688: | * 'signatureAlgorithm' => XMLDSigToken::RSA_SHA1, |
| 689: | * 'digestAlgorithm' => XMLDSigToken::SHA1 |
| 690: | * ]; |
| 691: | * ``` |
| 692: | * |
| 693: | * Available options are the following: |
| 694: | * |
| 695: | * - **`caCertPath`** [string|array] Analysis only. The path, or the list |
| 696: | * of paths, of PEM files holding the trusted CA certificates. The |
| 697: | * signing certificate is only trusted if OpenSSL builds and verifies |
| 698: | * its chain, from these certificates only, up to a self-signed |
| 699: | * certificate of these files (see `X509Cert::isTrustedBy()`). To trust |
| 700: | * the certificates issued by an intermediate CA, give its certificate |
| 701: | * and the certificates up to its root, in one file or in several: |
| 702: | * `['intermediate.cert.pem', 'root.cert.pem']`. A chain that stops at |
| 703: | * an intermediate CA is rejected: with the intermediate CA alone, |
| 704: | * `isSignatureValid()` returns FALSE and `getError()` returns "Signing |
| 705: | * certificate is not issued by a trusted CA: its chain could not be |
| 706: | * verified up to the CA certificates of the option 'caCertPath', which |
| 707: | * must hold the issuing CA and the certificates up to its root!". |
| 708: | * Only the signing certificate is taken from the token, so a |
| 709: | * certificate issued by another intermediate CA of the same root is |
| 710: | * rejected; a certificate issued directly by a CA of the files (the |
| 711: | * root included) is accepted. |
| 712: | * <br/>Default value: none. |
| 713: | * |
| 714: | * - **`signerCertPath`** [string] Analysis only. The path to the PEM |
| 715: | * certificate the token must be signed with (certificate pinning): the |
| 716: | * signing certificate must be this very certificate. Given alone, it is |
| 717: | * the trust anchor; given with `caCertPath`, both are required. |
| 718: | * <br/>Default value: none. |
| 719: | * |
| 720: | * - **`expectedSubject`** [string|array] Analysis only. The subject the |
| 721: | * signing certificate must have, either: |
| 722: | * |
| 723: | * - an array of the subject fields to check, as returned by |
| 724: | * `getCertSubject()`: `['O' => 'Lothbrok Ltd', 'OU' => 'Jarl Dept']`. |
| 725: | * Fields that are not given are not checked. |
| 726: | * |
| 727: | * - or a string, compared as is with the full Distinguished Name |
| 728: | * returned by `getCertDN()`. This is the OpenSSL "oneline" format |
| 729: | * (`openssl_x509_parse()`, key `name`): every field of the subject, |
| 730: | * in the order of the certificate, each written `/<short name>=<value>` |
| 731: | * with no space and no escaping, e.g. |
| 732: | * `'/C=DK/ST=Jylland/L=Kattegat/O=Lothbrok Ltd/OU=Signature Dept/CN=www.lothbrok.dk/emailAddress=ivar@lothbrok.dk'`. |
| 733: | * It is neither the RFC 2253 form (`CN=...,O=...`) nor the form |
| 734: | * printed by the `openssl` command line (`C=DK, ST=...`): copy it |
| 735: | * from `getCertDN()` or `X509Cert::getDN()`. |
| 736: | * |
| 737: | * Character case is significant. It does not replace a trust anchor. |
| 738: | * <br/>Default value: none. |
| 739: | * |
| 740: | * - **`expectedAudience`** [string] Analysis only. The audience of this |
| 741: | * receiver, which the token must be addressed to (see `audience`): |
| 742: | * `isSignatureValid()` returns FALSE if the token names no audience or |
| 743: | * does not name this one. A token that names audiences is rejected |
| 744: | * when this option is not given, so that a receiver of the issuer |
| 745: | * cannot accept a token addressed to another one. Compared as is |
| 746: | * (character case included). |
| 747: | * <br/>Default value: none. |
| 748: | * |
| 749: | * - **`audience`** [string|array] Creation only. The audience, or the |
| 750: | * list of audiences, the token is addressed to: names of the receivers |
| 751: | * (an application name, a URL...), agreed between the issuer and each |
| 752: | * receiver. Each one is a non-empty string, with no surrounding white |
| 753: | * space and no control character. They are written in the token, |
| 754: | * covered by the signature (see `getAudience()`). |
| 755: | * <br/>Default value: none (the token names no audience). |
| 756: | * |
| 757: | * - **`validity`** [int] Creation only. The number of seconds after which |
| 758: | * the token is out of date, whatever the time to live the receiver |
| 759: | * gives to `isOutOfDate()`. The expiry date is written in the token, |
| 760: | * covered by the signature (see `getExpiry()`). |
| 761: | * <br/>Default value: none (only the receiver sets the time to live). |
| 762: | * |
| 763: | * - **`base64Encode`** [boolean] Creation only. Whether to base64 encode |
| 764: | * token data or not. |
| 765: | * <br/>Default value: **`TRUE`**. |
| 766: | * |
| 767: | * - **`xmldsigNsPrefix`** [string] Creation only. The XML namespace |
| 768: | * (xmlns) prefix to use in the XML digital signature tags. Set it to |
| 769: | * empty string to avoid prefix usage. |
| 770: | * <br/>Default value: **`'ds'`**. |
| 771: | * |
| 772: | * - **`checkSigningAlgorithms`** [boolean] Analysis only. Whether to |
| 773: | * check that the algorithms used to sign the token are the expected |
| 774: | * ones or not. Even when FALSE, an algorithm that is not in the lists |
| 775: | * below is rejected. |
| 776: | * <br/>Default value: **`TRUE`**. |
| 777: | * |
| 778: | * - **`checkCryptingAlgorithms`** [boolean] Analysis with a decryption |
| 779: | * key only. Whether to check that the algorithms used to encrypt the |
| 780: | * token are the expected ones or not. Even when FALSE, an algorithm |
| 781: | * that is not in the lists below is rejected. |
| 782: | * <br/>Default value: **`TRUE`**. |
| 783: | * |
| 784: | * - **`canonicalizationAlgorithm`** [string] Creation and analysis. The |
| 785: | * algorithm used to canonicalize the XML digital signature and to |
| 786: | * canonicalize token data before computing its hash. |
| 787: | * <br/>Possible values: |
| 788: | * |
| 789: | * - **`C14N`** (Default value) |
| 790: | * <br/>cf. http://www.w3.org/TR/2001/REC-xml-c14n-20010315 |
| 791: | * |
| 792: | * - `C14N_COMMENTS` |
| 793: | * <br/>cf. http://www.w3.org/TR/2001/REC-xml-c14n-20010315#WithComments |
| 794: | * |
| 795: | * - `EXC_C14N` |
| 796: | * <br/>cf. http://www.w3.org/2001/10/xml-exc-c14n# |
| 797: | * |
| 798: | * - `EXC_C14N_COMMENTS` |
| 799: | * <br/>cf. http://www.w3.org/2001/10/xml-exc-c14n#WithComments |
| 800: | * |
| 801: | * - **`signatureAlgorithm`** [string] Creation and analysis. The |
| 802: | * asymmetric algorithm used to sign the hash of token data. Key |
| 803: | * transport algorithms (see `cryptAlgorithm`) are rejected. |
| 804: | * <br/>Possible values: |
| 805: | * |
| 806: | * - `RSA_SHA1` |
| 807: | * <br/>cf. http://www.w3.org/2000/09/xmldsig#rsa-sha1 |
| 808: | * |
| 809: | * - **`RSA_SHA256`** (Default value) |
| 810: | * <br/>cf. http://www.w3.org/2001/04/xmldsig-more#rsa-sha256 |
| 811: | * |
| 812: | * - `RSA_SHA384` |
| 813: | * <br/>cf. http://www.w3.org/2001/04/xmldsig-more#rsa-sha384 |
| 814: | * |
| 815: | * - `RSA_SHA512` |
| 816: | * <br/>cf. http://www.w3.org/2001/04/xmldsig-more#rsa-sha512 |
| 817: | * |
| 818: | * - **`digestAlgorithm`** [string] Creation and analysis. The algorithm |
| 819: | * used to compute the hash of token data. |
| 820: | * <br/>Possible values: |
| 821: | * |
| 822: | * - `SHA1` |
| 823: | * <br/>cf. http://www.w3.org/2000/09/xmldsig#sha1 |
| 824: | * |
| 825: | * - **`SHA256`** (Default value) |
| 826: | * <br/>cf. http://www.w3.org/2001/04/xmlenc#sha256 |
| 827: | * |
| 828: | * - `SHA384` |
| 829: | * <br/>cf. http://www.w3.org/2001/04/xmldsig-more#sha384 |
| 830: | * |
| 831: | * - `SHA512` |
| 832: | * <br/>cf. http://www.w3.org/2001/04/xmlenc#sha512 |
| 833: | * |
| 834: | * - `RIPEMD160` |
| 835: | * <br/>cf. http://www.w3.org/2001/04/xmlenc#ripemd160 |
| 836: | * |
| 837: | * - **`sessionKeyCipheringAlgorithm`** [string] Encryption only |
| 838: | * (creation with an encryption certificate, analysis with a decryption |
| 839: | * key). The symmetric algorithm used to encrypt token data with a |
| 840: | * generated session key. |
| 841: | * <br/>Possible values: |
| 842: | * |
| 843: | * - `AES128_GCM` |
| 844: | * <br/>cf. http://www.w3.org/2009/xmlenc11#aes128-gcm |
| 845: | * |
| 846: | * - `AES192_GCM` |
| 847: | * <br/>cf. http://www.w3.org/2009/xmlenc11#aes192-gcm |
| 848: | * |
| 849: | * - **`AES256_GCM`** (Default value) |
| 850: | * <br/>cf. http://www.w3.org/2009/xmlenc11#aes256-gcm |
| 851: | * |
| 852: | * - `TRIPLEDES_CBC` |
| 853: | * <br/>cf. http://www.w3.org/2001/04/xmlenc#tripledes-cbc |
| 854: | * |
| 855: | * - `AES128_CBC` |
| 856: | * <br/>cf. http://www.w3.org/2001/04/xmlenc#aes128-cbc |
| 857: | * |
| 858: | * - `AES192_CBC` |
| 859: | * <br/>cf. http://www.w3.org/2001/04/xmlenc#aes192-cbc |
| 860: | * |
| 861: | * - `AES256_CBC` |
| 862: | * <br/>cf. http://www.w3.org/2001/04/xmlenc#aes256-cbc |
| 863: | * |
| 864: | * The CBC algorithms do not protect the integrity of the data: only |
| 865: | * the signature does. An analysis only decrypts a token encrypted with |
| 866: | * one of them if this option names it, even when |
| 867: | * `checkCryptingAlgorithms` is FALSE. |
| 868: | * |
| 869: | * - **`cryptAlgorithm`** [string] Encryption only (creation with an |
| 870: | * encryption certificate, analysis with a decryption key). The |
| 871: | * asymmetric algorithm used to encrypt the session key with the |
| 872: | * encryption certificate (key transport). Signature algorithms (see |
| 873: | * `signatureAlgorithm`) are rejected, and so is RSA 1.5 |
| 874: | * (http://www.w3.org/2001/04/xmlenc#rsa-1_5), at creation as at |
| 875: | * analysis. |
| 876: | * <br/>Possible values: |
| 877: | * |
| 878: | * - **`RSA_OAEP_MGF1P`** (Default value) |
| 879: | * <br/>cf. http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p |
| 880: | * |
| 881: | * @param mixed $xmlOrData An XML token (string) or token data (associative |
| 882: | * array). |
| 883: | * |
| 884: | * @param string $signKeyPath The path to the PEM private key file that will |
| 885: | * be used to sign the token. |
| 886: | * |
| 887: | * @param string $signKeyPassword The password, if needed, to access the |
| 888: | * private key that will be used to sign the token. Use NULL if no |
| 889: | * password needed. |
| 890: | * |
| 891: | * @param string $signCertPath The path to the PEM X.509 certificate file |
| 892: | * that will be included in the XML digital signature, in order to |
| 893: | * verify the signature. |
| 894: | * |
| 895: | * @param string|null $cryptKeyPath The path to the PEM private key file |
| 896: | * that will be used to decrypt the session key which was used to |
| 897: | * encrypt the token. Required to analyze an encrypted token, optional |
| 898: | * to create one. |
| 899: | * |
| 900: | * @param string|null $cryptKeyPassword The password to access the private |
| 901: | * key that will be used to decrypt the session key which was used to |
| 902: | * encrypt the token. Use NULL if no password needed. |
| 903: | * |
| 904: | * @param string $cryptCertPath The path to the PEM X.509 certificate file |
| 905: | * that will be used to encrypt the session key which will be used to |
| 906: | * encrypt the token. |
| 907: | * |
| 908: | * @param array $options Configuration options. |
| 909: | * |
| 910: | * @throws Exception |
| 911: | * |
| 912: | * @see createXMLToken(), createSecureXMLToken(), analyzeXMLToken(), |
| 913: | * analyzeSecureXMLToken() |
| 914: | */ |
| 915: | protected function __construct( |
| 916: | $xmlOrData, |
| 917: | $signKeyPath = null, |
| 918: | $signKeyPassword = null, |
| 919: | $signCertPath = null, |
| 920: | $cryptKeyPath = null, |
| 921: | $cryptKeyPassword = null, |
| 922: | $cryptCertPath = null, |
| 923: | $options = [] |
| 924: | ) |
| 925: | { |
| 926: | if (!is_array($xmlOrData) && !is_string($xmlOrData)) |
| 927: | { |
| 928: | throw new Exception("Invalid parameter 'xmlOrData'! Parameter must be either an array or a string!"); |
| 929: | } |
| 930: | |
| 931: | // Check the options against what the object is created for: an |
| 932: | // unknown option, or an option that does not apply here, throws |
| 933: | // (see _checkOptions()). |
| 934: | $isCreation = is_array($xmlOrData); |
| 935: | self::_checkOptions($options, $isCreation, !is_null($isCreation ? $cryptCertPath : $cryptKeyPath)); |
| 936: | |
| 937: | // Load configuration options. |
| 938: | $base64Encode = isset($options['base64Encode']) ? $options['base64Encode'] : true; |
| 939: | |
| 940: | $xmldsigNsPrefix = isset($options['xmldsigNsPrefix']) ? $options['xmldsigNsPrefix'] : null; |
| 941: | |
| 942: | $checkSigningAlgorithms = isset($options['checkSigningAlgorithms']) ? $options['checkSigningAlgorithms'] : true; |
| 943: | $checkCryptingAlgorithms = isset($options['checkCryptingAlgorithms']) ? $options['checkCryptingAlgorithms'] : true; |
| 944: | |
| 945: | $canonicalizationAlgorithm = isset($options['canonicalizationAlgorithm']) ? $options['canonicalizationAlgorithm'] : null; |
| 946: | $signatureAlgorithm = isset($options['signatureAlgorithm']) ? $options['signatureAlgorithm'] : null; |
| 947: | $digestAlgorithm = isset($options['digestAlgorithm']) ? $options['digestAlgorithm'] : null; |
| 948: | $sessionKeyCipheringAlgorithm = isset($options['sessionKeyCipheringAlgorithm']) ? $options['sessionKeyCipheringAlgorithm'] : null; |
| 949: | $cryptAlgorithm = isset($options['cryptAlgorithm']) ? $options['cryptAlgorithm'] : null; |
| 950: | |
| 951: | // If provided, set and store the canonicalization algorithm. |
| 952: | if (!is_null($canonicalizationAlgorithm)) |
| 953: | { |
| 954: | if (self::_isValidCanonicalizationAlgorithm($canonicalizationAlgorithm)) |
| 955: | { |
| 956: | $this->canonicalizationAlgorithm = $canonicalizationAlgorithm; |
| 957: | $this->transformAlgorithm = $canonicalizationAlgorithm; |
| 958: | } |
| 959: | else |
| 960: | { |
| 961: | throw new Exception("Invalid parameter 'canonicalizationAlgorithm'! Unknown canonicalization algorithm."); |
| 962: | } |
| 963: | } |
| 964: | |
| 965: | // If provided, set and store the signature algorithm. |
| 966: | if (!is_null($signatureAlgorithm)) |
| 967: | { |
| 968: | if (self::_isValidSignatureAlgorithm($signatureAlgorithm)) |
| 969: | { |
| 970: | $this->signatureAlgorithm = $signatureAlgorithm; |
| 971: | } |
| 972: | else |
| 973: | { |
| 974: | throw new Exception("Invalid parameter 'signatureAlgorithm'! Unknown signature algorithm."); |
| 975: | } |
| 976: | } |
| 977: | |
| 978: | // If provided, set and store the digest algorithm. |
| 979: | if (!is_null($digestAlgorithm)) |
| 980: | { |
| 981: | if (self::_isValidHashAlgorithm($digestAlgorithm)) |
| 982: | { |
| 983: | $this->digestAlgorithm = $digestAlgorithm; |
| 984: | } |
| 985: | else |
| 986: | { |
| 987: | throw new Exception("Invalid parameter 'digestAlgorithm'! Unknown digest algorithm."); |
| 988: | } |
| 989: | } |
| 990: | |
| 991: | // If requested, disable check of signature algorithms. |
| 992: | if (false === $checkSigningAlgorithms) |
| 993: | { |
| 994: | $this->checkSigningAlgorithms = false; |
| 995: | } |
| 996: | |
| 997: | // Check what have to be done according to the type of $xmlOrData |
| 998: | // parameter. |
| 999: | if ($isCreation) |
| 1000: | { |
| 1001: | // $xmlOrData is an array, so we have to create an XML token. |
| 1002: | |
| 1003: | // Check that $data is a non-empty array. |
| 1004: | if (empty($xmlOrData)) |
| 1005: | { |
| 1006: | throw new Exception("Invalid parameter 'data'! Token data should not be an empty array."); |
| 1007: | } |
| 1008: | |
| 1009: | // Check that $data is an associative array. |
| 1010: | if (count(array_filter(array_keys($xmlOrData), 'is_string')) == 0) |
| 1011: | { |
| 1012: | throw new Exception("Invalid parameter 'data'! Token data should be an associative array."); |
| 1013: | } |
| 1014: | |
| 1015: | // If requested, disable base64 encoding of token data. |
| 1016: | if (false === $base64Encode) |
| 1017: | { |
| 1018: | $this->base64EncodeData = false; |
| 1019: | } |
| 1020: | |
| 1021: | // If provided, store the audiences and the validity of the token |
| 1022: | // (checked by _checkOptions()). |
| 1023: | if (isset($options['audience'])) |
| 1024: | { |
| 1025: | $this->audienceToWrite = (array) $options['audience']; |
| 1026: | } |
| 1027: | if (isset($options['validity'])) |
| 1028: | { |
| 1029: | $this->validity = $options['validity']; |
| 1030: | } |
| 1031: | |
| 1032: | // Check that required private key and X.509 certificate |
| 1033: | // are provided, in order to sign the token. |
| 1034: | if (is_null($signKeyPath)) |
| 1035: | { |
| 1036: | throw new Exception("Missing parameter 'signKeyPath'! A private key is required to sign token."); |
| 1037: | } |
| 1038: | |
| 1039: | // Check that required private key and X.509 certificate |
| 1040: | // are provided, in order to sign the token. |
| 1041: | if (is_null($signCertPath)) |
| 1042: | { |
| 1043: | throw new Exception("Missing parameter 'signCertPath'! An X.509 certificate is required to sign token."); |
| 1044: | } |
| 1045: | |
| 1046: | // Get and store the private key that will be used to sign the token |
| 1047: | // data. |
| 1048: | $signKey = @file_get_contents($signKeyPath); |
| 1049: | if (false === $signKey) |
| 1050: | { |
| 1051: | throw new Exception("Invalid parameter 'signKeyPath'! Cannot read private key to sign token."); |
| 1052: | } |
| 1053: | $this->signKey = $signKey; |
| 1054: | |
| 1055: | // If provided, store the password to access the private key that |
| 1056: | // will be used to sign the token data. |
| 1057: | if (!is_null($signKeyPassword)) |
| 1058: | { |
| 1059: | if (!is_string($signKeyPassword)) |
| 1060: | { |
| 1061: | throw new Exception("Invalid parameter 'signKeyPassword'! Parameter must be a string."); |
| 1062: | } |
| 1063: | $this->signKeyPassword = $signKeyPassword; |
| 1064: | } |
| 1065: | |
| 1066: | // Get and store the X.509 certificate that will be included in the |
| 1067: | // XML token. |
| 1068: | $signCert = @file_get_contents($signCertPath); |
| 1069: | if (false === $signCert) |
| 1070: | { |
| 1071: | throw new Exception("Invalid parameter 'signCertPath'! Cannot read X.509 certificate to sign token."); |
| 1072: | } |
| 1073: | $this->signCert = $signCert; |
| 1074: | |
| 1075: | // The created token is analyzed to check it (see _writeXML()): it |
| 1076: | // must be signed with this very certificate. |
| 1077: | $this->pinnedSignerCert = $signCert; |
| 1078: | |
| 1079: | // If provided, store the prefix namespace to use in the XML digital |
| 1080: | // signature tags. |
| 1081: | if (!is_null($xmldsigNsPrefix)) |
| 1082: | { |
| 1083: | if (!is_string($xmldsigNsPrefix)) |
| 1084: | { |
| 1085: | throw new Exception("Invalid parameter 'xmldsigNsPrefix'! Parameter must be a string."); |
| 1086: | } |
| 1087: | $this->xmldsigNsPrefix = $xmldsigNsPrefix; |
| 1088: | } |
| 1089: | |
| 1090: | // If provided, get and store X.509 certificate that will be used |
| 1091: | // to encrypt the session key which will be used to encrypt the |
| 1092: | // token data. |
| 1093: | if (!is_null($cryptCertPath)) |
| 1094: | { |
| 1095: | $cryptCert = @file_get_contents($cryptCertPath); |
| 1096: | if (false === $cryptCert) |
| 1097: | { |
| 1098: | throw new Exception("Invalid parameter 'cryptCertPath'! Cannot read X.509 certificate to encrypt token."); |
| 1099: | } |
| 1100: | $this->cryptCert = $cryptCert; |
| 1101: | |
| 1102: | // If provided, get and store the private key of the recipient, |
| 1103: | // which decrypts the session key: it is only used to check |
| 1104: | // the decrypted content of the created token. Without it, the |
| 1105: | // created token is checked up to its decryption (see |
| 1106: | // _readXML()), so that the sender needs no private key of the |
| 1107: | // recipient. |
| 1108: | if (!is_null($cryptKeyPath)) |
| 1109: | { |
| 1110: | $cryptKey = @file_get_contents($cryptKeyPath); |
| 1111: | if (false === $cryptKey) |
| 1112: | { |
| 1113: | throw new Exception("Invalid parameter 'cryptKeyPath'! Cannot read private key to decrypt token."); |
| 1114: | } |
| 1115: | $this->cryptKey = $cryptKey; |
| 1116: | } |
| 1117: | |
| 1118: | // If provided, store the password to access the private key |
| 1119: | // that will be used to decrypt the session key. A password |
| 1120: | // without a key would be ignored: it throws instead. |
| 1121: | if (!is_null($cryptKeyPassword)) |
| 1122: | { |
| 1123: | if (is_null($cryptKeyPath)) |
| 1124: | { |
| 1125: | throw new Exception("Invalid parameter 'cryptKeyPassword'! It only applies with the parameter 'cryptKeyPath'."); |
| 1126: | } |
| 1127: | if (!is_string($cryptKeyPassword)) |
| 1128: | { |
| 1129: | throw new Exception("Invalid parameter 'cryptKeyPassword'! Parameter must be a string."); |
| 1130: | } |
| 1131: | $this->cryptKeyPassword = $cryptKeyPassword; |
| 1132: | } |
| 1133: | |
| 1134: | // If provided, set and store the session key algorithm. |
| 1135: | if (!is_null($sessionKeyCipheringAlgorithm)) |
| 1136: | { |
| 1137: | if (self::_isValidSymmetricCipheringAlgorithm($sessionKeyCipheringAlgorithm)) |
| 1138: | { |
| 1139: | $this->sessionKeyCipheringAlgorithm = $sessionKeyCipheringAlgorithm; |
| 1140: | } |
| 1141: | else |
| 1142: | { |
| 1143: | throw new Exception("Invalid parameter 'sessionKeyCipheringAlgorithm'! Unknown session key ciphering algorithm."); |
| 1144: | } |
| 1145: | } |
| 1146: | |
| 1147: | // If provided, set and store the crypt algorithm. |
| 1148: | if (!is_null($cryptAlgorithm)) |
| 1149: | { |
| 1150: | if (self::_isValidKeyTransportAlgorithm($cryptAlgorithm)) |
| 1151: | { |
| 1152: | $this->cryptAlgorithm = $cryptAlgorithm; |
| 1153: | } |
| 1154: | else |
| 1155: | { |
| 1156: | throw new Exception("Invalid parameter 'cryptAlgorithm'! Unknown crypt algorithm."); |
| 1157: | } |
| 1158: | } |
| 1159: | |
| 1160: | // If requested, disable check of used crypt algorithms. |
| 1161: | if (false === $checkCryptingAlgorithms) |
| 1162: | { |
| 1163: | $this->checkCryptingAlgorithms = false; |
| 1164: | } |
| 1165: | } |
| 1166: | |
| 1167: | // Create an XML token with the provided data. |
| 1168: | try |
| 1169: | { |
| 1170: | // NOTE: This function will call the _readXML() function which |
| 1171: | // will verify that the created token is correct and which will |
| 1172: | // terminate setting object properties. |
| 1173: | $this->_writeXML($xmlOrData); |
| 1174: | } |
| 1175: | catch (Exception $e) |
| 1176: | { |
| 1177: | // ERROR: XML token analysis failed on fatal error. |
| 1178: | $this->error = $e->getMessage(); |
| 1179: | } |
| 1180: | } |
| 1181: | else |
| 1182: | { |
| 1183: | // $xmlOrData is a string, so we have to analyze the XML token. |
| 1184: | |
| 1185: | // Load the trust anchor. Its absence is not an error here: the |
| 1186: | // analysis then reports that no certificate can be trusted (see |
| 1187: | // _verifyCertTrust()). |
| 1188: | $this->_loadTrustOptions($options); |
| 1189: | |
| 1190: | // If provided, store the audience the token must be addressed to |
| 1191: | // (checked by _checkOptions(), see _verifyAudience()). |
| 1192: | if (isset($options['expectedAudience'])) |
| 1193: | { |
| 1194: | $this->expectedAudience = $options['expectedAudience']; |
| 1195: | } |
| 1196: | |
| 1197: | // If provided, get and store the private key that will be used |
| 1198: | // to decrypt the session key which was used to encrypt the token. |
| 1199: | if (!is_null($cryptKeyPath)) |
| 1200: | { |
| 1201: | $cryptKey = @file_get_contents($cryptKeyPath); |
| 1202: | if (false === $cryptKey) |
| 1203: | { |
| 1204: | throw new Exception("Invalid parameter 'cryptKeyPath'! Cannot read private key to decrypt token."); |
| 1205: | } |
| 1206: | $this->cryptKey = $cryptKey; |
| 1207: | |
| 1208: | // If provided, store the password to access the private key |
| 1209: | // that will be used to decrypt the session key. |
| 1210: | if (!is_null($cryptKeyPassword)) |
| 1211: | { |
| 1212: | if (!is_string($cryptKeyPassword)) |
| 1213: | { |
| 1214: | throw new Exception("Invalid parameter 'cryptKeyPassword'! Parameter must be a string."); |
| 1215: | } |
| 1216: | $this->cryptKeyPassword = $cryptKeyPassword; |
| 1217: | } |
| 1218: | |
| 1219: | // If provided, set and store the session key algorithm. |
| 1220: | if (!is_null($sessionKeyCipheringAlgorithm)) |
| 1221: | { |
| 1222: | if (self::_isValidSymmetricCipheringAlgorithm($sessionKeyCipheringAlgorithm)) |
| 1223: | { |
| 1224: | $this->sessionKeyCipheringAlgorithm = $sessionKeyCipheringAlgorithm; |
| 1225: | } |
| 1226: | else |
| 1227: | { |
| 1228: | throw new Exception("Invalid parameter 'sessionKeyCipheringAlgorithm'! Unknown session key ciphering algorithm."); |
| 1229: | } |
| 1230: | } |
| 1231: | |
| 1232: | // If provided, set and store the crypt algorithm. |
| 1233: | if (!is_null($cryptAlgorithm)) |
| 1234: | { |
| 1235: | if (self::_isValidKeyTransportAlgorithm($cryptAlgorithm)) |
| 1236: | { |
| 1237: | $this->cryptAlgorithm = $cryptAlgorithm; |
| 1238: | } |
| 1239: | else |
| 1240: | { |
| 1241: | throw new Exception("Invalid parameter 'cryptAlgorithm'! Unknown crypt algorithm."); |
| 1242: | } |
| 1243: | } |
| 1244: | |
| 1245: | // If requested, disable check of used crypt algorithms. |
| 1246: | if (false === $checkCryptingAlgorithms) |
| 1247: | { |
| 1248: | $this->checkCryptingAlgorithms = false; |
| 1249: | } |
| 1250: | } |
| 1251: | |
| 1252: | // Parse the provided XML token to ensure all is correct and to |
| 1253: | // terminate initializing object properties. |
| 1254: | try |
| 1255: | { |
| 1256: | $this->_readXML($xmlOrData); |
| 1257: | } |
| 1258: | catch (Exception $e) |
| 1259: | { |
| 1260: | // ERROR: XML token analysis failed on fatal error. |
| 1261: | $this->error = $e->getMessage(); |
| 1262: | } |
| 1263: | } |
| 1264: | } |
| 1265: | |
| 1266: | |
| 1267: | /** |
| 1268: | * Check the options given to the object against what it is created for. |
| 1269: | * |
| 1270: | * Each option is only read in its context (see `OPTION_CONTEXTS`): token |
| 1271: | * creation, token analysis, both ("any"), encryption (creation with an |
| 1272: | * encryption certificate, or analysis with a decryption key) or |
| 1273: | * decryption (analysis with a decryption key). A misspelt option, or an |
| 1274: | * option given out of its context, would be ignored without notice and |
| 1275: | * leave the token without the configuration the caller asked for: it |
| 1276: | * throws an exception instead, as does an option whose value is NULL or |
| 1277: | * of the wrong type. The values of the algorithm and trust options are |
| 1278: | * checked where they are loaded. |
| 1279: | * |
| 1280: | * @param array $options Configuration options. |
| 1281: | * |
| 1282: | * @param boolean $isCreation TRUE if the object creates a token, FALSE if |
| 1283: | * it analyzes one. |
| 1284: | * |
| 1285: | * @param boolean $isEncryption TRUE if an encryption certificate (token |
| 1286: | * creation) or a decryption key (token analysis) is given. |
| 1287: | * |
| 1288: | * @throws Exception |
| 1289: | */ |
| 1290: | static private function _checkOptions($options, $isCreation, $isEncryption) |
| 1291: | { |
| 1292: | if (!is_array($options)) |
| 1293: | { |
| 1294: | throw new Exception("Invalid parameter 'options'! Parameter must be an array."); |
| 1295: | } |
| 1296: | |
| 1297: | foreach ($options as $name => $value) |
| 1298: | { |
| 1299: | if (!isset(self::OPTION_CONTEXTS[$name])) |
| 1300: | { |
| 1301: | throw new Exception("Unknown option '" . $name . "'!"); |
| 1302: | } |
| 1303: | |
| 1304: | switch (self::OPTION_CONTEXTS[$name]) |
| 1305: | { |
| 1306: | case 'creation': |
| 1307: | if (!$isCreation) |
| 1308: | { |
| 1309: | throw new Exception("Option '" . $name . "' only applies to token creation!"); |
| 1310: | } |
| 1311: | break; |
| 1312: | case 'analysis': |
| 1313: | if ($isCreation) |
| 1314: | { |
| 1315: | throw new Exception("Option '" . $name . "' only applies to token analysis! A created token is checked with the certificate of its own signing key."); |
| 1316: | } |
| 1317: | break; |
| 1318: | case 'encryption': |
| 1319: | if (!$isEncryption) |
| 1320: | { |
| 1321: | throw new Exception("Option '" . $name . "' only applies to an encrypted token! Give the encryption certificate (createSecureXMLToken()) or the decryption key (analyzeSecureXMLToken())."); |
| 1322: | } |
| 1323: | break; |
| 1324: | case 'decryption': |
| 1325: | if ($isCreation || !$isEncryption) |
| 1326: | { |
| 1327: | throw new Exception("Option '" . $name . "' only applies to the analysis of an encrypted token! Give the decryption key (analyzeSecureXMLToken())."); |
| 1328: | } |
| 1329: | break; |
| 1330: | } |
| 1331: | |
| 1332: | if (is_null($value)) |
| 1333: | { |
| 1334: | throw new Exception("Invalid option '" . $name . "'! Its value must not be NULL."); |
| 1335: | } |
| 1336: | |
| 1337: | if (in_array($name, ['base64Encode', 'checkSigningAlgorithms', 'checkCryptingAlgorithms'], true) && !is_bool($value)) |
| 1338: | { |
| 1339: | throw new Exception("Invalid option '" . $name . "'! It must be a boolean."); |
| 1340: | } |
| 1341: | |
| 1342: | if ('xmldsigNsPrefix' === $name && !is_string($value)) |
| 1343: | { |
| 1344: | throw new Exception("Invalid option '" . $name . "'! It must be a string."); |
| 1345: | } |
| 1346: | |
| 1347: | if ('audience' === $name) |
| 1348: | { |
| 1349: | $audiences = is_array($value) ? $value : [$value]; |
| 1350: | if (empty($audiences) || $audiences !== array_values($audiences) || $audiences !== array_filter($audiences, [self::class, '_isValidAudience'])) |
| 1351: | { |
| 1352: | throw new Exception("Invalid option 'audience'! It must be a non-empty string, or a non-empty list of them, without control characters."); |
| 1353: | } |
| 1354: | if (count(array_unique($audiences)) !== count($audiences)) |
| 1355: | { |
| 1356: | throw new Exception("Invalid option 'audience'! An audience is given twice."); |
| 1357: | } |
| 1358: | } |
| 1359: | |
| 1360: | if ('expectedAudience' === $name && !self::_isValidAudience($value)) |
| 1361: | { |
| 1362: | throw new Exception("Invalid option 'expectedAudience'! It must be a non-empty string without control characters."); |
| 1363: | } |
| 1364: | |
| 1365: | if ('validity' === $name && !(is_int($value) && $value > 0)) |
| 1366: | { |
| 1367: | throw new Exception("Invalid option 'validity'! It must be a number of seconds greater than 0."); |
| 1368: | } |
| 1369: | |
| 1370: | // The expiry is written with a four-digit year: a validity that |
| 1371: | // takes it past the year 9999 (or past the range of an integer) |
| 1372: | // could not be written, and would be computed wrong. |
| 1373: | if ('validity' === $name && $value > self::MAX_EXPIRY_TIME - time()) |
| 1374: | { |
| 1375: | throw new Exception("Invalid option 'validity'! The expiry would be after 9999-12-31T23:59:59Z."); |
| 1376: | } |
| 1377: | } |
| 1378: | } |
| 1379: | |
| 1380: | |
| 1381: | /** |
| 1382: | * Indicates whether a value can be an audience of a token (options |
| 1383: | * "audience" and "expectedAudience"): a non-empty UTF-8 string, without |
| 1384: | * surrounding white space, made of characters XML 1.0 can hold as text. |
| 1385: | * |
| 1386: | * Control characters are refused: XML 1.0 cannot hold most of them, and |
| 1387: | * the others (tab, line feed, carriage return) are normalized by XML |
| 1388: | * parsers, so that the audience read back would differ from the one |
| 1389: | * written. So are U+FFFE and U+FFFF, which XML 1.0 cannot hold either. |
| 1390: | * |
| 1391: | * @param mixed $audience The value to check. |
| 1392: | * |
| 1393: | * @return boolean TRUE if the value can be an audience, FALSE otherwise. |
| 1394: | */ |
| 1395: | static private function _isValidAudience($audience) |
| 1396: | { |
| 1397: | return is_string($audience) |
| 1398: | && '' !== $audience |
| 1399: | && trim($audience) === $audience |
| 1400: | && 1 === preg_match('//u', $audience) |
| 1401: | && 0 === preg_match('/[\x00-\x1F\x7F]|[^\x{20}-\x{D7FF}\x{E000}-\x{FFFD}\x{10000}-\x{10FFFF}]/u', $audience); |
| 1402: | } |
| 1403: | |
| 1404: | |
| 1405: | /** |
| 1406: | * Load and check the trust options of an analysis ("caCertPath", |
| 1407: | * "signerCertPath" and "expectedSubject", see `__construct()`). |
| 1408: | * |
| 1409: | * A trust option of the wrong type, or naming a file that cannot be read, |
| 1410: | * throws an exception instead of being ignored: ignoring it would leave |
| 1411: | * the token without the constraint the caller asked for. |
| 1412: | * |
| 1413: | * @param array $options Configuration options. |
| 1414: | * |
| 1415: | * @throws Exception |
| 1416: | */ |
| 1417: | private function _loadTrustOptions($options) |
| 1418: | { |
| 1419: | if (isset($options['caCertPath'])) |
| 1420: | { |
| 1421: | $caCertPaths = $options['caCertPath']; |
| 1422: | if (is_string($caCertPaths)) |
| 1423: | { |
| 1424: | $caCertPaths = [$caCertPaths]; |
| 1425: | } |
| 1426: | if (!is_array($caCertPaths) || empty($caCertPaths)) |
| 1427: | { |
| 1428: | throw new Exception("Invalid option 'caCertPath'! It must be a path or a non-empty list of paths."); |
| 1429: | } |
| 1430: | foreach ($caCertPaths as $caCertPath) |
| 1431: | { |
| 1432: | if (!is_string($caCertPath) || !is_file($caCertPath) || !is_readable($caCertPath)) |
| 1433: | { |
| 1434: | throw new Exception("Invalid option 'caCertPath'! Cannot read CA certificate file: " . (is_string($caCertPath) ? $caCertPath : gettype($caCertPath)) . "."); |
| 1435: | } |
| 1436: | } |
| 1437: | $this->trustedCACertPaths = array_values($caCertPaths); |
| 1438: | } |
| 1439: | |
| 1440: | if (isset($options['signerCertPath'])) |
| 1441: | { |
| 1442: | $signerCert = is_string($options['signerCertPath']) ? @file_get_contents($options['signerCertPath']) : false; |
| 1443: | if (false === $signerCert || false === @openssl_x509_read($signerCert)) |
| 1444: | { |
| 1445: | throw new Exception("Invalid option 'signerCertPath'! Cannot read the pinned X.509 certificate."); |
| 1446: | } |
| 1447: | $this->pinnedSignerCert = $signerCert; |
| 1448: | } |
| 1449: | |
| 1450: | if (isset($options['expectedSubject'])) |
| 1451: | { |
| 1452: | $expectedSubject = $options['expectedSubject']; |
| 1453: | if (!(is_string($expectedSubject) || is_array($expectedSubject)) || empty($expectedSubject)) |
| 1454: | { |
| 1455: | throw new Exception("Invalid option 'expectedSubject'! It must be a non-empty string or array."); |
| 1456: | } |
| 1457: | $this->expectedSubject = $expectedSubject; |
| 1458: | } |
| 1459: | } |
| 1460: | |
| 1461: | |
| 1462: | /** |
| 1463: | * Creates an XML token for the given user data. |
| 1464: | * |
| 1465: | * The created XML token can be retrieved using the function `getXML()`. |
| 1466: | * |
| 1467: | * @param array $data Token data (an associative array that may be |
| 1468: | * multi-dimensional). |
| 1469: | * |
| 1470: | * @param string $signKeyPath The path to the PEM private key file that will |
| 1471: | * be used to sign the token. |
| 1472: | * |
| 1473: | * @param string $signCertPath The path to the PEM X.509 certificate file |
| 1474: | * that will be included in the XML digital signature, in order to |
| 1475: | * verify the signature. |
| 1476: | * |
| 1477: | * @param string $signKeyPassword The password to access the private key |
| 1478: | * that will be used to sign the token. Use NULL if no password needed. |
| 1479: | * |
| 1480: | * @param array $options Configuration options |
| 1481: | * (see `__construct()` for details). |
| 1482: | * |
| 1483: | * @return XMLDSigToken An XMLDSigToken object. If the token cannot be |
| 1484: | * created, `getError()` tells why. |
| 1485: | * |
| 1486: | * @throws Exception |
| 1487: | * |
| 1488: | * @see __construct() |
| 1489: | */ |
| 1490: | public static function createXMLToken( |
| 1491: | $data, |
| 1492: | $signKeyPath, |
| 1493: | $signCertPath, |
| 1494: | $signKeyPassword = null, |
| 1495: | $options = [] |
| 1496: | ) |
| 1497: | { |
| 1498: | return new XMLDSigToken( |
| 1499: | $data, |
| 1500: | $signKeyPath, |
| 1501: | $signKeyPassword, |
| 1502: | $signCertPath, |
| 1503: | $cryptKeyPath = null, |
| 1504: | $cryptKeyPassword = null, |
| 1505: | $cryptCertPath = null, |
| 1506: | $options |
| 1507: | ); |
| 1508: | } |
| 1509: | |
| 1510: | |
| 1511: | /** |
| 1512: | * Creates a secure (encrypted) XML token for the given user data. |
| 1513: | * |
| 1514: | * The created XML token can be retrieved using the function `getXML()`. |
| 1515: | * |
| 1516: | * The token is encrypted for the owner of the encryption certificate |
| 1517: | * (`$cryptCertPath`): only the private key of this certificate decrypts |
| 1518: | * it. The sender does not need this private key: pass NULL as |
| 1519: | * `$cryptKeyPath`. |
| 1520: | * |
| 1521: | * ```php |
| 1522: | * $token = XMLDSigToken::createSecureXMLToken($data, $signKeyPath, $signCertPath, null, $cryptCertPath, $signKeyPassword); |
| 1523: | * ``` |
| 1524: | * |
| 1525: | * The created token is checked right away: |
| 1526: | * |
| 1527: | * - Without `$cryptKeyPath`, the check covers everything that can be |
| 1528: | * verified without decrypting the token: its structure, its |
| 1529: | * algorithms, its digest, its signature and the trust in the signing |
| 1530: | * certificate. `isSignatureValid()` and `isDataEncrypted()` return |
| 1531: | * TRUE, while `getData()`, `getTimestamp()` and `getTokenId()` return |
| 1532: | * NULL: they are inside the encrypted content, which is not decrypted. |
| 1533: | * |
| 1534: | * - With `$cryptKeyPath`, the token is also decrypted and its content |
| 1535: | * read, as by `analyzeSecureXMLToken()`: `getData()`, `getTimestamp()` |
| 1536: | * and `getTokenId()` return what the decrypted token holds. |
| 1537: | * |
| 1538: | * @param array $data Token data (an associative array that may be |
| 1539: | * multi-dimensional). |
| 1540: | * |
| 1541: | * @param string $signKeyPath The path to the PEM private key file that will |
| 1542: | * be used to sign the token. |
| 1543: | * |
| 1544: | * @param string $signCertPath The path to the PEM X.509 certificate file |
| 1545: | * that will be included in the XML digital signature, in order to |
| 1546: | * verify the signature. |
| 1547: | * |
| 1548: | * @param string|null $cryptKeyPath The path to the PEM private key file of |
| 1549: | * the recipient, associated to `$cryptCertPath`, used to decrypt the |
| 1550: | * created token in order to check its content. Use NULL to create the |
| 1551: | * token without it. |
| 1552: | * |
| 1553: | * @param string $cryptCertPath The path to the PEM X.509 certificate file |
| 1554: | * that will be used to encrypt the session key which will be used to |
| 1555: | * encrypt the token. |
| 1556: | * |
| 1557: | * @param string $signKeyPassword The password to access the private key |
| 1558: | * that will be used to sign the token. Use NULL if no password needed. |
| 1559: | * |
| 1560: | * @param string|null $cryptKeyPassword The password to access the private |
| 1561: | * key `$cryptKeyPath`. Use NULL if no password needed, and when |
| 1562: | * `$cryptKeyPath` is NULL. |
| 1563: | * |
| 1564: | * @param array $options Configuration options |
| 1565: | * (see `__construct()` for details). |
| 1566: | * |
| 1567: | * @return XMLDSigToken An XMLDSigToken object. If the token cannot be |
| 1568: | * created, `getError()` tells why. |
| 1569: | * |
| 1570: | * @throws Exception |
| 1571: | * |
| 1572: | * @see __construct() |
| 1573: | */ |
| 1574: | public static function createSecureXMLToken( |
| 1575: | $data, |
| 1576: | $signKeyPath, |
| 1577: | $signCertPath, |
| 1578: | $cryptKeyPath, |
| 1579: | $cryptCertPath, |
| 1580: | $signKeyPassword = null, |
| 1581: | $cryptKeyPassword = null, |
| 1582: | $options = [] |
| 1583: | ) |
| 1584: | { |
| 1585: | // Without an encryption certificate, the constructor would create an |
| 1586: | // unencrypted token. |
| 1587: | if (is_null($cryptCertPath)) |
| 1588: | { |
| 1589: | throw new Exception("Missing parameter 'cryptCertPath'! An X.509 certificate is required to encrypt token."); |
| 1590: | } |
| 1591: | |
| 1592: | return new XMLDSigToken( |
| 1593: | $data, |
| 1594: | $signKeyPath, |
| 1595: | $signKeyPassword, |
| 1596: | $signCertPath, |
| 1597: | $cryptKeyPath, |
| 1598: | $cryptKeyPassword, |
| 1599: | $cryptCertPath, |
| 1600: | $options |
| 1601: | ); |
| 1602: | } |
| 1603: | |
| 1604: | |
| 1605: | /** |
| 1606: | * Parse an XML token. |
| 1607: | * |
| 1608: | * The options must hold a trust anchor, `caCertPath` and/or |
| 1609: | * `signerCertPath`; without it, `isSignatureValid()` returns FALSE. |
| 1610: | * |
| 1611: | * ```php |
| 1612: | * $token = XMLDSigToken::analyzeXMLToken($xml, [ |
| 1613: | * 'caCertPath' => ['path/to/intermediate.cert.pem', 'path/to/root.cert.pem'], |
| 1614: | * ]); |
| 1615: | * ``` |
| 1616: | * |
| 1617: | * @param string $xml An XML token. |
| 1618: | * |
| 1619: | * @param array $options Configuration options, trust anchor included |
| 1620: | * (see `__construct()` for details). |
| 1621: | * |
| 1622: | * @return XMLDSigToken An XMLDSigToken object. If the token is not |
| 1623: | * valid, `isSignatureValid()` returns FALSE and `getError()` tells |
| 1624: | * why. |
| 1625: | * |
| 1626: | * @throws Exception |
| 1627: | * |
| 1628: | * @see __construct() |
| 1629: | */ |
| 1630: | public static function analyzeXMLToken( |
| 1631: | $xml, |
| 1632: | $options = [] |
| 1633: | ) |
| 1634: | { |
| 1635: | return new XMLDSigToken( |
| 1636: | $xml, |
| 1637: | $signKeyPath = null, |
| 1638: | $signKeyPassword = null, |
| 1639: | $signCertPath = null, |
| 1640: | $cryptKeyPath = null, |
| 1641: | $cryptKeyPassword = null, |
| 1642: | $cryptCertPath = null, |
| 1643: | $options |
| 1644: | ); |
| 1645: | } |
| 1646: | |
| 1647: | |
| 1648: | /** |
| 1649: | * Parse an XML token whose data are encrypted. |
| 1650: | * |
| 1651: | * NOTE: An unencrypted token can also be parsed using this function. |
| 1652: | * |
| 1653: | * The options must hold a trust anchor, `caCertPath` and/or |
| 1654: | * `signerCertPath`; without it, `isSignatureValid()` returns FALSE. |
| 1655: | * |
| 1656: | * @param string $xml An XML token. |
| 1657: | * |
| 1658: | * @param string $cryptKeyPath The path to the PEM private key file that |
| 1659: | * will be used to decrypt the session key which was used to encrypt |
| 1660: | * token data. |
| 1661: | * |
| 1662: | * @param string $cryptKeyPassword The password to access the private key |
| 1663: | * that will be used to decrypt the session key which was used to |
| 1664: | * encrypt the token. Use NULL if no password needed. |
| 1665: | * |
| 1666: | * @param array $options Configuration options, trust anchor included |
| 1667: | * (see `__construct()` for details). |
| 1668: | * |
| 1669: | * @return XMLDSigToken An XMLDSigToken object. If the token is not |
| 1670: | * valid, `isSignatureValid()` returns FALSE and `getError()` tells |
| 1671: | * why. |
| 1672: | * |
| 1673: | * @throws Exception |
| 1674: | * |
| 1675: | * @see __construct() |
| 1676: | */ |
| 1677: | public static function analyzeSecureXMLToken( |
| 1678: | $xml, |
| 1679: | $cryptKeyPath, |
| 1680: | $cryptKeyPassword = null, |
| 1681: | $options = [] |
| 1682: | ) |
| 1683: | { |
| 1684: | return new XMLDSigToken( |
| 1685: | $xml, |
| 1686: | $signKeyPath = null, |
| 1687: | $signKeyPassword = null, |
| 1688: | $signCertPath = null, |
| 1689: | $cryptKeyPath, |
| 1690: | $cryptKeyPassword, |
| 1691: | $cryptCertPath = null, |
| 1692: | $options |
| 1693: | ); |
| 1694: | } |
| 1695: | |
| 1696: | |
| 1697: | /** |
| 1698: | * Write the XML Digital Signature representing the XML token for the |
| 1699: | * provided user data. |
| 1700: | * |
| 1701: | * The XML token can then be obtained using the `getXML()` function. |
| 1702: | * |
| 1703: | * @param array $data A flat or multidimensional associative array |
| 1704: | * containing the token data. |
| 1705: | * |
| 1706: | * @throws Exception |
| 1707: | */ |
| 1708: | private function _writeXML($data) |
| 1709: | { |
| 1710: | // Whether to base64 encode data or not. |
| 1711: | $base64Encode = $this->base64EncodeData; |
| 1712: | |
| 1713: | // Create an XML document to hold the token data. |
| 1714: | $xmlDoc = new DOMDocument('1.0', 'UTF-8'); |
| 1715: | |
| 1716: | // Do not format the XML digital signature when it will be saved as an |
| 1717: | // XML string. |
| 1718: | $xmlDoc->formatOutput = false; |
| 1719: | |
| 1720: | // Create the root node "Token" that holds the whole content of the |
| 1721: | // token. |
| 1722: | // NOTE: This node is explicitly created without namespace if no |
| 1723: | // namespace is used for XML digital signature tags. |
| 1724: | if ($this->xmldsigNsPrefix === '') |
| 1725: | { |
| 1726: | $tokenElement = $xmlDoc->createElementNS('', self::TOKEN_NAME); |
| 1727: | } |
| 1728: | else |
| 1729: | { |
| 1730: | $tokenElement = $xmlDoc->createElement(self::TOKEN_NAME); |
| 1731: | } |
| 1732: | $tokenNode = $xmlDoc->appendChild($tokenElement); |
| 1733: | |
| 1734: | // Create a node "timestamp" that holds the token timestamp in the |
| 1735: | // format "YYYY-MM-DDThh:mm:ssZ", using UTC timezone, and append it to |
| 1736: | // the node "Token". |
| 1737: | $dt = new DateTime(); |
| 1738: | $dt->setTimeZone(new DateTimeZone('UTC')); |
| 1739: | $tokenTimestampElement = $xmlDoc->createElement('TokenTimestamp', $dt->format(self::TOKEN_TIMESTAMP_FORMAT)); |
| 1740: | $tokenTimestampNode = $tokenNode->appendChild($tokenTimestampElement); |
| 1741: | |
| 1742: | // Create a node "TokenId" that holds a random identifier, in |
| 1743: | // hexadecimal, and append it to the node "Token". The signature |
| 1744: | // covers it: the receiver can refuse a token whose identifier it has |
| 1745: | // already accepted (see getTokenId()). |
| 1746: | $tokenIdElement = $xmlDoc->createElement(self::TOKEN_ID_NAME, bin2hex(random_bytes(self::TOKEN_ID_BYTES))); |
| 1747: | $tokenNode->appendChild($tokenIdElement); |
| 1748: | |
| 1749: | // If a validity is given, create a node "TokenExpiry" that holds the |
| 1750: | // date after which the token is out of date (see isOutOfDate()), in |
| 1751: | // the format of the timestamp, and append it to the node "Token". |
| 1752: | if (!is_null($this->validity)) |
| 1753: | { |
| 1754: | $expiry = clone $dt; |
| 1755: | $expiry->modify('+' . $this->validity . ' seconds'); |
| 1756: | $tokenNode->appendChild($xmlDoc->createElement(self::TOKEN_EXPIRY_NAME, $expiry->format(self::TOKEN_TIMESTAMP_FORMAT))); |
| 1757: | } |
| 1758: | |
| 1759: | // Create a node "TokenAudience" per audience the token is addressed to |
| 1760: | // (see getAudience()), and append them to the node "Token". The value |
| 1761: | // is added as a text node: the second argument of createElement() |
| 1762: | // would read "&" as the start of an entity reference. |
| 1763: | foreach ((array) $this->audienceToWrite as $audience) |
| 1764: | { |
| 1765: | $audienceElement = $xmlDoc->createElement(self::TOKEN_AUDIENCE_NAME); |
| 1766: | $audienceElement->appendChild($xmlDoc->createTextNode($audience)); |
| 1767: | $tokenNode->appendChild($audienceElement); |
| 1768: | } |
| 1769: | |
| 1770: | /** |
| 1771: | * Inline function to build a node tree, whose root node will be named |
| 1772: | * $nodeName, from the array $data and append it to the given |
| 1773: | * $parentNode node. The values of the leaves are base64 encoded by this |
| 1774: | * function. |
| 1775: | * |
| 1776: | * NOTE: This function is called recursively if $data is a |
| 1777: | * multi-dimensional associative array. |
| 1778: | * |
| 1779: | * @param array $data A flat or multi-dimensional associative array that |
| 1780: | * holds the data to convert in a node tree. |
| 1781: | * |
| 1782: | * @param string $nodeName The name of the node that will hold the node |
| 1783: | * tree. |
| 1784: | * |
| 1785: | * @param DOMNode $parentNode An existing DOM node to which to append |
| 1786: | * the node tree. |
| 1787: | */ |
| 1788: | $dataToNodeTree = function($data, $nodeName, $parentNode) use (&$dataToNodeTree, $base64Encode) |
| 1789: | { |
| 1790: | if(is_array($data)) |
| 1791: | { |
| 1792: | // Appends node. |
| 1793: | $element = $parentNode->ownerDocument->createElement($nodeName); |
| 1794: | $dataNode = $parentNode->appendChild($element); |
| 1795: | |
| 1796: | // Append child nodes/leaves recursively. |
| 1797: | array_walk($data, $dataToNodeTree, $dataNode); |
| 1798: | } |
| 1799: | else |
| 1800: | { |
| 1801: | // Appends leaf. |
| 1802: | $element = $parentNode->ownerDocument->createElement($nodeName, $base64Encode ? base64_encode($data) : $data); |
| 1803: | if ($base64Encode) |
| 1804: | { |
| 1805: | $attribute = $parentNode->ownerDocument->createAttribute('Algorithm'); |
| 1806: | $attribute->value = 'base64'; |
| 1807: | $element->appendChild($attribute); |
| 1808: | } |
| 1809: | $dataNode = $parentNode->appendChild($element); |
| 1810: | } |
| 1811: | }; |
| 1812: | |
| 1813: | // Create a node "TokenData" that holds a node tree representing the |
| 1814: | // token data encoded in base64 and append it to the node "Token". |
| 1815: | $dataToNodeTree($data, self::TOKEN_DATA_NAME, $tokenNode); |
| 1816: | |
| 1817: | // Create an XMLSecurityDSig object. |
| 1818: | // This object will hold the XML Digital Signature we are building. |
| 1819: | $XMLDSig = new XMLSecurityDSig($this->xmldsigNsPrefix); |
| 1820: | $XMLDSig->setCanonicalMethod($this->canonicalizationAlgorithm); |
| 1821: | |
| 1822: | // Add a node "ds:Object" to the XML Digital Signature and attach our |
| 1823: | // node "Token" to it. |
| 1824: | $objectNode = $XMLDSig->addObject($xmlDoc->documentElement); |
| 1825: | $objectNode->setAttribute('Id', self::TOKEN_NAME); |
| 1826: | |
| 1827: | // Encrypt our node "Token" if requested (if an X.509 certificate is |
| 1828: | // provided to perform encryption). |
| 1829: | if (!is_null($this->cryptCert)) |
| 1830: | { |
| 1831: | // Randomly generate a session key for the symmetric algorithm. |
| 1832: | // This key will be used to cipher our node "Token" (the content of |
| 1833: | // the node "ds:Object"). |
| 1834: | $sessionKey = new XMLSecurityKey($this->sessionKeyCipheringAlgorithm); |
| 1835: | $sessionKey->generateSessionKey(); |
| 1836: | |
| 1837: | // Create an asymmetric key from the X.509 certificate using RSA-OAEP |
| 1838: | // algorithm. This key will be used to encrypt the session key. |
| 1839: | $asymKey = new XMLSecurityKey($this->cryptAlgorithm, array('type' => 'public')); |
| 1840: | $asymKey->loadKey($this->cryptCert, false, true); |
| 1841: | if (empty($asymKey->key)) |
| 1842: | { |
| 1843: | // ERROR: Failed loading certificate to encrypt session key! |
| 1844: | throw new Exception("Failed loading certificate to encrypt session key!"); |
| 1845: | } |
| 1846: | |
| 1847: | // Encrypt the session key using the asymmetric key. |
| 1848: | $objXMLSecEnc = new XMLSecEnc(); |
| 1849: | $objXMLSecEnc->encryptKey($asymKey, $sessionKey); |
| 1850: | |
| 1851: | // Encrypt the content (cf. XMLSecEnc::Content) of the node |
| 1852: | // "ds:Object" that holds our node "Token" using the encrypted |
| 1853: | // session key. |
| 1854: | $objXMLSecEnc->setNode($objectNode); |
| 1855: | $objXMLSecEnc->type = XMLSecEnc::Content; |
| 1856: | $encryptedObjectNode = $objXMLSecEnc->encryptNode($sessionKey); |
| 1857: | } |
| 1858: | |
| 1859: | // Add a reference to the node "ds:Object" which is contained in the |
| 1860: | // XML Digital Signature itself (case of an enveloping signature). |
| 1861: | // This consists of: |
| 1862: | // - Adding a node "ds:Reference" to the node "ds:SignedInfo". |
| 1863: | // - Computing a hash, using the digest algorithm, from the |
| 1864: | // canonicalized XML string corresponding to the node "ds:Object". |
| 1865: | // - Adding this hash in a child node named "ds:DigestValue". |
| 1866: | // |
| 1867: | // NOTE: Only the first child node of the node "ds:Object" is taken in |
| 1868: | // account to generate the hash which will then be signed. |
| 1869: | $XMLDSig->addReference($objectNode, $this->digestAlgorithm, NULL, array('overwrite' => false, 'force_uri' => true)); |
| 1870: | |
| 1871: | // Create the private key object that will be used to sign the token |
| 1872: | // applying the signature algorithm. If a password is provided, use it |
| 1873: | // to access the private key. |
| 1874: | $privateKey = new XMLSecurityKey($this->signatureAlgorithm, array('type' => 'private')); |
| 1875: | if (!is_null($this->signKeyPassword)) |
| 1876: | { |
| 1877: | $privateKey->passphrase = $this->signKeyPassword; |
| 1878: | } |
| 1879: | |
| 1880: | $privateKey->loadKey($this->signKey, false, false); |
| 1881: | if (empty($privateKey->key)) |
| 1882: | { |
| 1883: | // ERROR: Failed loading private key to sign token data! |
| 1884: | throw new Exception("Failed loading private key to sign token data!"); |
| 1885: | } |
| 1886: | |
| 1887: | // Add the X.509 certificate associated to the private key to the XML |
| 1888: | // Digital Signature. So, the recipient of the XML token will not need |
| 1889: | // to own it, in order to verify the signature. It will simply get it |
| 1890: | // from the XML token. This consists of adding a node "ds:KeyInfo" that |
| 1891: | // holds the content of the X.509 certificate. |
| 1892: | $XMLDSig->add509Cert($this->signCert); |
| 1893: | |
| 1894: | // Sign the token data using the private security key associated to the |
| 1895: | // X.509 certificate. |
| 1896: | // This consists of: |
| 1897: | // - Computing the signature of the node "ds:SignedInfo". |
| 1898: | // - Adding a node named "ds:SignatureValue" that contains the |
| 1899: | // computed signature. |
| 1900: | $XMLDSig->sign($privateKey); |
| 1901: | |
| 1902: | // Check that the created XML token is well formed (if it's valid and if |
| 1903: | // timestamp and data can be retrieved from it) and, by the way, |
| 1904: | // terminate to initialize the properties of our XMLDSigToken object. |
| 1905: | $this->_readXML($XMLDSig->sigNode->ownerDocument->saveXML(), true); |
| 1906: | } |
| 1907: | |
| 1908: | |
| 1909: | /** |
| 1910: | * Read the XML Digital Signature representing an XML token. |
| 1911: | * |
| 1912: | * This function will check that the provided XML Digital Signature is |
| 1913: | * valid and that its certificate is trusted (see `_verifyCertTrust()`), |
| 1914: | * will decrypt the token data it contains if this one is encrypted |
| 1915: | * and then will extract timestamp and data. |
| 1916: | * |
| 1917: | * The data can then be obtained using the `getData()` function. Nevertheless, |
| 1918: | * you have to ensure that you can trust it using `isSignatureValid()` and |
| 1919: | * `isOutOfDate()`. |
| 1920: | * |
| 1921: | * NOTE: This function is called by the `_writeXML()` function to ensure the |
| 1922: | * XML token it built is correct and because this function sets most of the |
| 1923: | * properties of the XMLDSigToken object that are issued from the analysis |
| 1924: | * of the XML token. A token created encrypted without the private key of |
| 1925: | * the recipient is checked up to its decryption: structure, algorithms, |
| 1926: | * digest, signature and trust in the signing certificate. Its content is |
| 1927: | * not decrypted, and neither its timestamp, its identifier nor its data |
| 1928: | * are read. |
| 1929: | * |
| 1930: | * @param string $xml The XML Digital Signature representing an XML token. |
| 1931: | * |
| 1932: | * @param boolean $isCreation TRUE if the object has just created the |
| 1933: | * token. |
| 1934: | * |
| 1935: | * @throws Exception |
| 1936: | */ |
| 1937: | private function _readXML($xml, $isCreation = false) |
| 1938: | { |
| 1939: | // Store the provided XML Digital Signature. |
| 1940: | $this->xml = $xml; |
| 1941: | |
| 1942: | // Load the enveloping XML Digital Signature into a DOM document, |
| 1943: | // preserving its formatting. A document with a document type |
| 1944: | // declaration is refused before it is parsed (see _loadXML()). |
| 1945: | $xmlDoc = self::_loadXML($xml, "XML Digital Signature"); |
| 1946: | |
| 1947: | // Instantiate and store a DOMXpath object to navigate through the DOM |
| 1948: | // document that holds the XML Digital Signature. |
| 1949: | $this->xpath = new DOMXPath($xmlDoc); |
| 1950: | $this->xpath->registerNamespace('ds', XMLSecurityDSig::XMLDSIGNS); |
| 1951: | $this->xpath->registerNamespace('xenc', XMLSecEnc::XMLENCNS); |
| 1952: | |
| 1953: | // xmlseclibs computes the digest over the first element of the |
| 1954: | // document whose attribute "Id" is "Token" and, when "ds:Signature" is |
| 1955: | // not the document element, it first detaches "ds:Signature" from the |
| 1956: | // document. A second element "Id=Token", or a "ds:Signature" wrapped |
| 1957: | // in another element, thus lets a copy of the signed "ds:Object", |
| 1958: | // placed where the signature does not cover it, be digested instead of |
| 1959: | // the "ds:Object" the token is read from (signature wrapping). Such |
| 1960: | // documents are rejected. The token is also read only from the node |
| 1961: | // xmlseclibs digested (see below). |
| 1962: | $root = $xmlDoc->documentElement; |
| 1963: | if (XMLSecurityDSig::XMLDSIGNS !== $root->namespaceURI || 'Signature' !== $root->localName) |
| 1964: | { |
| 1965: | // ERROR: XML Digital Signature is malformed! |
| 1966: | throw new Exception("XML digital signature is malformed! The document element must be ds:Signature."); |
| 1967: | } |
| 1968: | if ($this->xpath->query('//@*[local-name()="Id" and .="' . self::TOKEN_NAME . '"]')->length !== 1) |
| 1969: | { |
| 1970: | // ERROR: XML Digital Signature is malformed! |
| 1971: | throw new Exception("XML digital signature is malformed! Exactly one element must have the attribute Id=\"" . self::TOKEN_NAME . "\"."); |
| 1972: | } |
| 1973: | |
| 1974: | // Verify that the XML Digital Signature is well formed. |
| 1975: | $queries = array( |
| 1976: | "//ds:Signature", |
| 1977: | "//ds:Signature/ds:SignedInfo", |
| 1978: | "//ds:Signature/ds:SignedInfo/ds:CanonicalizationMethod", |
| 1979: | "//ds:Signature/ds:SignedInfo/ds:SignatureMethod", |
| 1980: | "//ds:Signature/ds:SignedInfo/ds:Reference", |
| 1981: | "//ds:Signature/ds:SignedInfo/ds:Reference/ds:Transforms", |
| 1982: | "//ds:Signature/ds:SignedInfo/ds:Reference/ds:Transforms/ds:Transform", |
| 1983: | "//ds:Signature/ds:SignedInfo/ds:Reference/ds:DigestMethod", |
| 1984: | "//ds:Signature/ds:SignedInfo/ds:Reference/ds:DigestValue", |
| 1985: | "//ds:Signature/ds:SignatureValue", |
| 1986: | "//ds:Signature/ds:KeyInfo", |
| 1987: | "//ds:Signature/ds:KeyInfo/ds:X509Data", |
| 1988: | "//ds:Signature/ds:KeyInfo/ds:X509Data/ds:X509Certificate", |
| 1989: | "//ds:Signature/ds:Object" |
| 1990: | ); |
| 1991: | |
| 1992: | foreach ($queries as $query) |
| 1993: | { |
| 1994: | if ($this->xpath->query($query)->length !== 1) |
| 1995: | { |
| 1996: | // ERROR: XML Digital Signature is malformed! |
| 1997: | throw new Exception("XML digital signature is malformed!"); |
| 1998: | } |
| 1999: | } |
| 2000: | |
| 2001: | $queries = array( |
| 2002: | // "//ds:Signature/@xmlns" => XMLSecurityDSig::XMLDSIGNS, |
| 2003: | "//ds:Signature/ds:SignedInfo/ds:Reference/@URI" => '#' . self::TOKEN_NAME, |
| 2004: | "//ds:Signature/ds:Object/@Id" => self::TOKEN_NAME |
| 2005: | ); |
| 2006: | |
| 2007: | foreach ($queries as $query => $value) |
| 2008: | { |
| 2009: | if ($this->xpath->evaluate("string(" . $query . ")") !== $value) |
| 2010: | { |
| 2011: | // ERROR: XML digital signature is malformed! |
| 2012: | throw new Exception("XML digital signature is malformed!"); |
| 2013: | } |
| 2014: | } |
| 2015: | |
| 2016: | // Verify the algorithms used to sign the token. |
| 2017: | $queries = array( |
| 2018: | "//ds:Signature/ds:SignedInfo/ds:CanonicalizationMethod/@Algorithm" |
| 2019: | => array( |
| 2020: | 'name' => 'Canonicalization', |
| 2021: | 'validateFunction' => '_isValidCanonicalizationAlgorithm', |
| 2022: | 'expectedValue' => $this->canonicalizationAlgorithm |
| 2023: | ), |
| 2024: | "//ds:Signature/ds:SignedInfo/ds:SignatureMethod/@Algorithm" |
| 2025: | => array( |
| 2026: | 'name' => 'Signature', |
| 2027: | 'validateFunction' => '_isValidSignatureAlgorithm', |
| 2028: | 'expectedValue' => $this->signatureAlgorithm |
| 2029: | ), |
| 2030: | "//ds:Signature/ds:SignedInfo/ds:Reference/ds:Transforms/ds:Transform/@Algorithm" |
| 2031: | => array( |
| 2032: | 'name' => 'Transform', |
| 2033: | 'validateFunction' => '_isValidCanonicalizationAlgorithm', |
| 2034: | 'expectedValue' => $this->transformAlgorithm |
| 2035: | ), |
| 2036: | "//ds:Signature/ds:SignedInfo/ds:Reference/ds:DigestMethod/@Algorithm" |
| 2037: | => array( |
| 2038: | 'name' => 'Digest', |
| 2039: | 'validateFunction' => '_isValidHashAlgorithm', |
| 2040: | 'expectedValue' => $this->digestAlgorithm |
| 2041: | ) |
| 2042: | ); |
| 2043: | |
| 2044: | foreach ($queries as $query => $algorithm) |
| 2045: | { |
| 2046: | $usedAlgorithm = $this->xpath->evaluate("string(" . $query . ")"); |
| 2047: | |
| 2048: | if (empty($usedAlgorithm)) |
| 2049: | { |
| 2050: | // ERROR: Algorithm is missing! |
| 2051: | throw new Exception($algorithm['name'] . " algorithm is missing!"); |
| 2052: | } |
| 2053: | |
| 2054: | if (!call_user_func(self::class . '::' . $algorithm['validateFunction'], $usedAlgorithm)) |
| 2055: | { |
| 2056: | // ERROR: Algorithm is invalid! |
| 2057: | throw new Exception($algorithm['name'] . " algorithm is invalid!"); |
| 2058: | } |
| 2059: | |
| 2060: | if ($usedAlgorithm !== $algorithm['expectedValue'] && $this->checkSigningAlgorithms) |
| 2061: | { |
| 2062: | // ANOMALY: Unauthorized algorithm! |
| 2063: | $this->anomalies[] = "Unauthorized " . $algorithm['name'] . " algorithm! Expected: " . $algorithm['expectedValue'] . ", Used: " . $usedAlgorithm . "."; |
| 2064: | } |
| 2065: | } |
| 2066: | |
| 2067: | // Check whether the token is encrypted or not. |
| 2068: | if ($this->xpath->query("//ds:Signature/ds:Object/xenc:EncryptedData")->length === 1) |
| 2069: | { |
| 2070: | // Verify that the encrypted data is well formed. |
| 2071: | $queries = array( |
| 2072: | "//ds:Signature/ds:Object/xenc:EncryptedData/xenc:EncryptionMethod", |
| 2073: | "//ds:Signature/ds:Object/xenc:EncryptedData/ds:KeyInfo", |
| 2074: | "//ds:Signature/ds:Object/xenc:EncryptedData/ds:KeyInfo/xenc:EncryptedKey", |
| 2075: | "//ds:Signature/ds:Object/xenc:EncryptedData/ds:KeyInfo/xenc:EncryptedKey/xenc:EncryptionMethod", |
| 2076: | "//ds:Signature/ds:Object/xenc:EncryptedData/ds:KeyInfo/xenc:EncryptedKey/xenc:CipherData", |
| 2077: | "//ds:Signature/ds:Object/xenc:EncryptedData/ds:KeyInfo/xenc:EncryptedKey/xenc:CipherData/xenc:CipherValue", |
| 2078: | "//ds:Signature/ds:Object/xenc:EncryptedData/xenc:CipherData", |
| 2079: | "//ds:Signature/ds:Object/xenc:EncryptedData/xenc:CipherData/xenc:CipherValue" |
| 2080: | ); |
| 2081: | |
| 2082: | foreach ($queries as $query) |
| 2083: | { |
| 2084: | if ($this->xpath->query($query)->length !== 1) |
| 2085: | { |
| 2086: | // ERROR: Encrypted data is malformed! |
| 2087: | throw new Exception("Encrypted data is malformed!"); |
| 2088: | } |
| 2089: | } |
| 2090: | |
| 2091: | $queries = array( |
| 2092: | "//ds:Signature/ds:Object/xenc:EncryptedData/@Type" => XMLSecEnc::Content |
| 2093: | ); |
| 2094: | |
| 2095: | foreach ($queries as $query => $value) |
| 2096: | { |
| 2097: | if ($this->xpath->evaluate("string(" . $query . ")") !== $value) |
| 2098: | { |
| 2099: | // ERROR: Encrypted data is malformed! |
| 2100: | throw new Exception("Encrypted data is malformed!"); |
| 2101: | } |
| 2102: | } |
| 2103: | |
| 2104: | // Verify the algorithms used to encrypt the token. |
| 2105: | $queries = array( |
| 2106: | "//ds:Signature/ds:Object/xenc:EncryptedData/xenc:EncryptionMethod/@Algorithm" |
| 2107: | => array( |
| 2108: | 'name' => 'Session Key Ciphering', |
| 2109: | 'validateFunction' => '_isValidSymmetricCipheringAlgorithm', |
| 2110: | 'expectedValue' => $this->sessionKeyCipheringAlgorithm |
| 2111: | ), |
| 2112: | "//ds:Signature/ds:Object/xenc:EncryptedData/ds:KeyInfo/xenc:EncryptedKey/xenc:EncryptionMethod/@Algorithm" |
| 2113: | => array( |
| 2114: | 'name' => 'Crypt', |
| 2115: | 'validateFunction' => '_isValidKeyTransportAlgorithm', |
| 2116: | 'expectedValue' => $this->cryptAlgorithm |
| 2117: | ) |
| 2118: | ); |
| 2119: | |
| 2120: | foreach ($queries as $query => $algorithm) |
| 2121: | { |
| 2122: | $usedAlgorithm = $this->xpath->evaluate("string(" . $query . ")"); |
| 2123: | |
| 2124: | if (empty($usedAlgorithm)) |
| 2125: | { |
| 2126: | // ERROR: Algorithm is missing! |
| 2127: | throw new Exception($algorithm['name'] . " algorithm is missing!"); |
| 2128: | } |
| 2129: | |
| 2130: | if (!call_user_func(self::class . '::' . $algorithm['validateFunction'], $usedAlgorithm)) |
| 2131: | { |
| 2132: | // ERROR: Algorithm is invalid! |
| 2133: | throw new Exception($algorithm['name'] . " algorithm is invalid!"); |
| 2134: | } |
| 2135: | |
| 2136: | if ($usedAlgorithm !== $algorithm['expectedValue'] && $this->checkCryptingAlgorithms) |
| 2137: | { |
| 2138: | // ANOMALY: Unauthorized algorithm! |
| 2139: | $this->anomalies[] = "Unauthorized " . $algorithm['name'] . " algorithm! Expected: " . $algorithm['expectedValue'] . ", Used: " . $usedAlgorithm . "."; |
| 2140: | } |
| 2141: | } |
| 2142: | |
| 2143: | // An algorithm without integrity (CBC) is only accepted when it |
| 2144: | // is the declared one, even if the check of the algorithms is |
| 2145: | // turned off (see _isAuthenticatedCipheringAlgorithm()). |
| 2146: | $usedAlgorithm = $this->xpath->evaluate("string(//ds:Signature/ds:Object/xenc:EncryptedData/xenc:EncryptionMethod/@Algorithm)"); |
| 2147: | if (!self::_isAuthenticatedCipheringAlgorithm($usedAlgorithm) |
| 2148: | && $usedAlgorithm !== $this->sessionKeyCipheringAlgorithm |
| 2149: | && !$this->checkCryptingAlgorithms) |
| 2150: | { |
| 2151: | // ANOMALY: Undeclared unauthenticated algorithm! |
| 2152: | $this->anomalies[] = "Unauthorized Session Key Ciphering algorithm! " . $usedAlgorithm . " has no integrity: declare it with the option 'sessionKeyCipheringAlgorithm' to accept it."; |
| 2153: | } |
| 2154: | } |
| 2155: | |
| 2156: | // Create an XMLSecurityDSig object that rejects empty or failed |
| 2157: | // canonicalizations (see _createVerifier()). |
| 2158: | $XMLDSig = self::_createVerifier(); |
| 2159: | |
| 2160: | // Locate the node "ds:Signature". |
| 2161: | $nodeSignature = $XMLDSig->locateSignature($xmlDoc); |
| 2162: | if (is_null($nodeSignature)) |
| 2163: | { |
| 2164: | // ERROR: Cannot locate signature! |
| 2165: | throw new Exception("Cannot locate signature!"); |
| 2166: | } |
| 2167: | |
| 2168: | // Build an XMLSecurityKey object from the X.509 certificate which is |
| 2169: | // included in the XML Digital Signature. |
| 2170: | $objKeyX509 = $XMLDSig->locateKey(); |
| 2171: | if (!$objKeyX509) |
| 2172: | { |
| 2173: | // ERROR: Cannot locate X.509 certificate! |
| 2174: | throw new Exception("Cannot locate X.509 certificate!"); |
| 2175: | } |
| 2176: | |
| 2177: | // Configure the $objKeyX509 XMLSecurityKey object with its related |
| 2178: | // information which should be stored in the node |
| 2179: | // "ds:Signature/ds:KeyInfo". |
| 2180: | if (is_null(XMLSecEnc::staticLocateKeyInfo($objKeyX509, $nodeSignature))) |
| 2181: | { |
| 2182: | // ERROR: Cannot locate key information for X.509 certificate! |
| 2183: | throw new Exception("Cannot locate key information for X.509 certificate!"); |
| 2184: | } |
| 2185: | |
| 2186: | // Create a new X509Cert object for the X.509 certificate found in the |
| 2187: | // XML Digital Signature. |
| 2188: | $this->x509Certificate = new X509Cert($objKeyX509->getX509Certificate()); |
| 2189: | |
| 2190: | // Verify that the hash computed from the node "ds:Signature/ds:Object" |
| 2191: | // is the same as the one stored in the node |
| 2192: | // "ds:Signature/ds:SignedInfo/ds:Reference/ds:DigestValue", and get |
| 2193: | // the node the hash was computed over. The token is read from this |
| 2194: | // node only, never by searching the document: data, timestamp and |
| 2195: | // encrypted content are then those the digest covers. |
| 2196: | $nodeObject = null; |
| 2197: | try |
| 2198: | { |
| 2199: | $XMLDSig->validateReference(); |
| 2200: | |
| 2201: | $validatedNodes = $XMLDSig->getValidatedNodes(); |
| 2202: | if (isset($validatedNodes[self::TOKEN_NAME])) |
| 2203: | { |
| 2204: | $nodeObject = $validatedNodes[self::TOKEN_NAME]; |
| 2205: | } |
| 2206: | if (is_null($nodeObject) || !$nodeObject->isSameNode($this->xpath->query("/ds:Signature/ds:Object")->item(0))) |
| 2207: | { |
| 2208: | throw new Exception("The digested node is not the node ds:Signature/ds:Object!"); |
| 2209: | } |
| 2210: | |
| 2211: | $this->isDigestValueOk = true; |
| 2212: | } |
| 2213: | catch (Exception $e) |
| 2214: | { |
| 2215: | // ANOMALY: Token hash verification failed! |
| 2216: | $nodeObject = null; |
| 2217: | $this->isDigestValueOk = false; |
| 2218: | $this->anomalies[] = "Token hash verification failed!"; |
| 2219: | } |
| 2220: | |
| 2221: | // Canonicalize the node "ds:Signature/ds:SignedInfo". The signature is |
| 2222: | // verified against this canonical form; the verifier throws an |
| 2223: | // exception if it is empty (see _createVerifier()). |
| 2224: | $XMLDSig->canonicalizeSignedInfo(); |
| 2225: | |
| 2226: | // Verifies that the signature held in the node |
| 2227: | // "ds:Signature/ds:SignatureValue" is correct for the node |
| 2228: | // "ds:Signature/ds:SignedInfo" using the key we've just built from the |
| 2229: | // X.509 certificate found in the XML Digital Signature. |
| 2230: | // The verification returns the value of openssl_verify(): 1 (valid), |
| 2231: | // 0 (invalid) or -1 (error). -1 is returned, whatever the signature |
| 2232: | // value, when the key of the certificate does not suit the signature |
| 2233: | // algorithm (e.g. an EC or DSA key with an RSA algorithm), and it is |
| 2234: | // TRUE in a boolean context: only a strict 1 makes the signature valid. |
| 2235: | try |
| 2236: | { |
| 2237: | $signatureVerification = $XMLDSig->verify($objKeyX509); |
| 2238: | } |
| 2239: | catch (\Throwable $e) |
| 2240: | { |
| 2241: | $signatureVerification = false; |
| 2242: | } |
| 2243: | |
| 2244: | if (1 === $signatureVerification) |
| 2245: | { |
| 2246: | // Token signature is valid. |
| 2247: | $this->isSignatureValueOk = true; |
| 2248: | } |
| 2249: | else |
| 2250: | { |
| 2251: | // ANOMALY: Token hash signature verification failed! |
| 2252: | $this->isSignatureValueOk = false; |
| 2253: | $this->anomalies[] = "Token hash signature verification failed!"; |
| 2254: | } |
| 2255: | |
| 2256: | // Neither decrypt nor read a token whose hash or hash signature is not |
| 2257: | // valid: its content is not the signed one. |
| 2258: | if (!$this->isDigestValueOk || !$this->isSignatureValueOk) |
| 2259: | { |
| 2260: | $this->_throwAnomalies(); |
| 2261: | } |
| 2262: | |
| 2263: | // The signature has only been verified with the certificate the token |
| 2264: | // carries, which anyone can produce. Neither decrypt nor read a token |
| 2265: | // whose certificate is not trusted. |
| 2266: | $this->_verifyCertTrust(); |
| 2267: | |
| 2268: | // Check if the content of the node "ds:Object" is ciphered. |
| 2269: | $encryptedDataNode = $this->xpath->query("xenc:EncryptedData", $nodeObject)->item(0); |
| 2270: | |
| 2271: | if (is_null($encryptedDataNode)) |
| 2272: | { |
| 2273: | // The node "ds:Object" is not ciphered. |
| 2274: | // Set encryption flag to FALSE. |
| 2275: | $this->isDataEncrypted = false; |
| 2276: | } |
| 2277: | else |
| 2278: | { |
| 2279: | // The node "ds:Object" is ciphered. |
| 2280: | // Set encryption flag to TRUE. |
| 2281: | $this->isDataEncrypted = true; |
| 2282: | |
| 2283: | // Do not decrypt a token encrypted with other algorithms than |
| 2284: | // the expected ones: the decryption itself is what an attacker |
| 2285: | // submitting altered tokens probes (see |
| 2286: | // _isValidKeyTransportAlgorithm() and |
| 2287: | // _isAuthenticatedCipheringAlgorithm()). |
| 2288: | if (!empty($this->anomalies)) |
| 2289: | { |
| 2290: | $this->_throwAnomalies(); |
| 2291: | } |
| 2292: | |
| 2293: | // A token created without the private key of the recipient |
| 2294: | // cannot be decrypted: everything that can be checked without |
| 2295: | // decrypting it has been, and its content is left unread. |
| 2296: | if ($isCreation && is_null($this->cryptKey)) |
| 2297: | { |
| 2298: | return; |
| 2299: | } |
| 2300: | |
| 2301: | // Decrypt it now. |
| 2302: | $this->_decryptToken($encryptedDataNode); |
| 2303: | } |
| 2304: | |
| 2305: | // Verify that the token data is well formed. The token is read from the |
| 2306: | // digested node "ds:Signature/ds:Object" (see above). |
| 2307: | $queries = array( |
| 2308: | self::TOKEN_NAME, |
| 2309: | self::TOKEN_NAME . "/" . self::TOKEN_TIMESTAMP_NAME, |
| 2310: | self::TOKEN_NAME . "/" . self::TOKEN_DATA_NAME, |
| 2311: | ); |
| 2312: | |
| 2313: | foreach ($queries as $query) |
| 2314: | { |
| 2315: | if ($this->xpath->query($query, $nodeObject)->length !== 1) |
| 2316: | { |
| 2317: | // ERROR: Malformed token! The structure of an encrypted |
| 2318: | // token is that of its decrypted content: it fails as the |
| 2319: | // decryption does (see _decryptToken()). |
| 2320: | throw new Exception($this->isDataEncrypted ? self::DECRYPTION_FAILED : "Malformed token!"); |
| 2321: | } |
| 2322: | } |
| 2323: | |
| 2324: | // Get the node "ds:Signature/ds:Object/Token/TokenTimestamp". |
| 2325: | $query = self::TOKEN_NAME . "/" . self::TOKEN_TIMESTAMP_NAME; |
| 2326: | $nodeTokenTimestamp = $this->xpath->query($query, $nodeObject)->item(0); |
| 2327: | |
| 2328: | // Check that token timestamp node is a DOM element that contains solely |
| 2329: | // a text node. |
| 2330: | if (XML_ELEMENT_NODE === $nodeTokenTimestamp->nodeType |
| 2331: | && $nodeTokenTimestamp->hasChildNodes() |
| 2332: | && 1 === $nodeTokenTimestamp->childNodes->length |
| 2333: | && XML_TEXT_NODE === $nodeTokenTimestamp->childNodes->item(0)->nodeType) |
| 2334: | { |
| 2335: | // Compute the UTC UNIX timestamp from the token timestamp string. |
| 2336: | $timezone = new DateTimeZone('UTC'); |
| 2337: | $UTCDate = DateTime::createFromFormat(self::TOKEN_TIMESTAMP_FORMAT, $nodeTokenTimestamp->nodeValue, $timezone); |
| 2338: | if (false !== $UTCDate) |
| 2339: | { |
| 2340: | // Store token timestamp. |
| 2341: | $this->timestamp = $nodeTokenTimestamp->nodeValue; |
| 2342: | } |
| 2343: | else |
| 2344: | { |
| 2345: | // ANOMALY: Token timestamp format is incorrect! |
| 2346: | $this->anomalies[] = "Token timestamp format is invalid!"; |
| 2347: | } |
| 2348: | } |
| 2349: | else |
| 2350: | { |
| 2351: | // ANOMALY: Malformed token timestamp. |
| 2352: | $this->anomalies[] = "Token timestamp is malformed!"; |
| 2353: | } |
| 2354: | |
| 2355: | // Get the node "ds:Signature/ds:Object/Token/TokenId". A token without |
| 2356: | // identifier (as created by the 0.x versions) is rejected: the |
| 2357: | // receiver could not tell it from a replayed one (see getTokenId()). |
| 2358: | $query = self::TOKEN_NAME . "/" . self::TOKEN_ID_NAME; |
| 2359: | $nodeTokenIds = $this->xpath->query($query, $nodeObject); |
| 2360: | if (0 === $nodeTokenIds->length) |
| 2361: | { |
| 2362: | // ANOMALY: Missing token identifier. |
| 2363: | $this->anomalies[] = "Token identifier is missing!"; |
| 2364: | } |
| 2365: | elseif (1 === $nodeTokenIds->length |
| 2366: | && 1 === $nodeTokenIds->item(0)->childNodes->length |
| 2367: | && XML_TEXT_NODE === $nodeTokenIds->item(0)->firstChild->nodeType |
| 2368: | && preg_match('/\A[0-9a-f]{' . (2 * self::TOKEN_ID_BYTES) . '}\z/', $nodeTokenIds->item(0)->nodeValue)) |
| 2369: | { |
| 2370: | // Store token identifier. |
| 2371: | $this->tokenId = $nodeTokenIds->item(0)->nodeValue; |
| 2372: | } |
| 2373: | else |
| 2374: | { |
| 2375: | // ANOMALY: Malformed token identifier. |
| 2376: | $this->anomalies[] = "Token identifier is malformed!"; |
| 2377: | } |
| 2378: | |
| 2379: | // Get the node "ds:Signature/ds:Object/Token/TokenExpiry", if any: a |
| 2380: | // token created without the option "validity" has none. |
| 2381: | $query = self::TOKEN_NAME . "/" . self::TOKEN_EXPIRY_NAME; |
| 2382: | $nodeTokenExpiries = $this->xpath->query($query, $nodeObject); |
| 2383: | $this->hasExpiryNode = 0 < $nodeTokenExpiries->length; |
| 2384: | if ($this->hasExpiryNode) |
| 2385: | { |
| 2386: | $timezone = new DateTimeZone('UTC'); |
| 2387: | $expiryDate = (1 === $nodeTokenExpiries->length && 1 === $nodeTokenExpiries->item(0)->childNodes->length && XML_TEXT_NODE === $nodeTokenExpiries->item(0)->firstChild->nodeType) |
| 2388: | ? DateTime::createFromFormat(self::TOKEN_TIMESTAMP_FORMAT, $nodeTokenExpiries->item(0)->nodeValue, $timezone) |
| 2389: | : false; |
| 2390: | $timestampDate = is_null($this->timestamp) ? false : DateTime::createFromFormat(self::TOKEN_TIMESTAMP_FORMAT, $this->timestamp, $timezone); |
| 2391: | if (false === $expiryDate || $expiryDate->format(self::TOKEN_TIMESTAMP_FORMAT) !== $nodeTokenExpiries->item(0)->nodeValue) |
| 2392: | { |
| 2393: | // ANOMALY: Malformed token expiry. |
| 2394: | $this->anomalies[] = "Token expiry is malformed!"; |
| 2395: | } |
| 2396: | elseif (false !== $timestampDate && $expiryDate <= $timestampDate) |
| 2397: | { |
| 2398: | // ANOMALY: Token expiring before it was issued. |
| 2399: | $this->anomalies[] = "Token expiry is not after its timestamp!"; |
| 2400: | } |
| 2401: | else |
| 2402: | { |
| 2403: | // Store token expiry. |
| 2404: | $this->expiry = $nodeTokenExpiries->item(0)->nodeValue; |
| 2405: | } |
| 2406: | } |
| 2407: | |
| 2408: | // Get the nodes "ds:Signature/ds:Object/Token/TokenAudience", if any, |
| 2409: | // and check them against the expected audience (see |
| 2410: | // _verifyAudience()) before the token data are read. |
| 2411: | $query = self::TOKEN_NAME . "/" . self::TOKEN_AUDIENCE_NAME; |
| 2412: | $nodeTokenAudiences = $this->xpath->query($query, $nodeObject); |
| 2413: | if (0 < $nodeTokenAudiences->length) |
| 2414: | { |
| 2415: | $audiences = []; |
| 2416: | foreach ($nodeTokenAudiences as $nodeTokenAudience) |
| 2417: | { |
| 2418: | if (1 !== $nodeTokenAudience->childNodes->length |
| 2419: | || XML_TEXT_NODE !== $nodeTokenAudience->firstChild->nodeType |
| 2420: | || !self::_isValidAudience($nodeTokenAudience->nodeValue)) |
| 2421: | { |
| 2422: | // ANOMALY: Malformed token audience. The token is |
| 2423: | // rejected before its data are read: a malformed |
| 2424: | // audience is not one the receiver can be sure of. |
| 2425: | $this->anomalies[] = "Token audience is malformed!"; |
| 2426: | $this->_throwAnomalies(); |
| 2427: | } |
| 2428: | $audiences[] = $nodeTokenAudience->nodeValue; |
| 2429: | } |
| 2430: | $this->audience = $audiences; |
| 2431: | } |
| 2432: | |
| 2433: | // A token whose timestamp, identifier or expiry is malformed is |
| 2434: | // rejected here, before its data are read and before its audience is |
| 2435: | // checked, so that the error lists these anomalies. |
| 2436: | if (!empty($this->anomalies)) |
| 2437: | { |
| 2438: | $this->_throwAnomalies(); |
| 2439: | } |
| 2440: | |
| 2441: | $this->_verifyAudience($isCreation); |
| 2442: | |
| 2443: | // Get the node "ds:Signature/ds:Object/Token/TokenData". |
| 2444: | $query = self::TOKEN_NAME . "/" . self::TOKEN_DATA_NAME; |
| 2445: | $nodeTokenData = $this->xpath->query($query, $nodeObject)->item(0); |
| 2446: | |
| 2447: | /** |
| 2448: | * Inline function to build a flat or multi-dimensional associative |
| 2449: | * array from a node tree whose leaf values are base64 encoded. The |
| 2450: | * values of the leaves are base64 decoded by this function. |
| 2451: | * |
| 2452: | * NOTE: This function is called recursively if the node tree is more |
| 2453: | * than one level deep. |
| 2454: | * |
| 2455: | * @param string $rootNode The node that holds the node tree. |
| 2456: | * |
| 2457: | * @param array $data The array to fill up with decoded data. |
| 2458: | */ |
| 2459: | $nodeTreeToData = function($rootNode, &$data) use (&$nodeTreeToData) |
| 2460: | { |
| 2461: | if (!$rootNode->hasChildNodes()) |
| 2462: | { |
| 2463: | // ANOMALY: Empty token data. |
| 2464: | $this->anomalies[] = "Token data is empty!"; |
| 2465: | } |
| 2466: | else |
| 2467: | { |
| 2468: | foreach ($rootNode->childNodes as $node) |
| 2469: | { |
| 2470: | if ((XML_TEXT_NODE === $node->nodeType && 1 !== $node->parentNode->childNodes->length)) |
| 2471: | { |
| 2472: | // ANOMALY: Malformed token data. |
| 2473: | $this->anomalies[] = "Token data is malformed!"; |
| 2474: | } |
| 2475: | if (XML_ELEMENT_NODE === $node->nodeType && $node->hasChildNodes()) |
| 2476: | { |
| 2477: | if (1 === $node->childNodes->length && XML_TEXT_NODE === $node->childNodes->item(0)->nodeType) |
| 2478: | { |
| 2479: | $decode = ("base64" === $node->getAttribute('Algorithm')) ? true : false; |
| 2480: | $data[$node->nodeName] = $decode ? base64_decode($node->childNodes->item(0)->nodeValue) : $node->childNodes->item(0)->nodeValue; |
| 2481: | } |
| 2482: | else |
| 2483: | { |
| 2484: | $data[$node->nodeName] = array(); |
| 2485: | |
| 2486: | // Recursive call. |
| 2487: | $nodeTreeToData($node, $data[$node->nodeName]); |
| 2488: | } |
| 2489: | } |
| 2490: | } |
| 2491: | } |
| 2492: | }; |
| 2493: | |
| 2494: | // Check that token data is well formed and extract data. |
| 2495: | $tokenData = array(); |
| 2496: | $nodeTreeToData($nodeTokenData, $tokenData); |
| 2497: | |
| 2498: | // Store token data. |
| 2499: | $this->data = $tokenData; |
| 2500: | |
| 2501: | // If any anomaly has been detected during analysis, set an error |
| 2502: | // message. |
| 2503: | if (!empty($this->anomalies)) |
| 2504: | { |
| 2505: | $this->_throwAnomalies(); |
| 2506: | } |
| 2507: | } |
| 2508: | |
| 2509: | |
| 2510: | /** |
| 2511: | * Load an XML document into a DOM document. |
| 2512: | * |
| 2513: | * A document type declaration (DOCTYPE) declares entities, which the |
| 2514: | * parser expands where the document references them: nested internal |
| 2515: | * entities make a small document expand into gigabytes ("billion |
| 2516: | * laughs"), and external entities read files or URLs. An XML token never |
| 2517: | * has one, so a document holding the bytes `<!DOCTYPE` is refused before |
| 2518: | * it is parsed, whatever it declares. This check reads the raw bytes: it |
| 2519: | * only holds for an encoding in which `<!DOCTYPE` can only be written as |
| 2520: | * these very bytes, which is why a document is also refused, before it is |
| 2521: | * parsed, when it is not in UTF-8, the encoding of the tokens: one that |
| 2522: | * holds a NUL byte (UTF-16, UTF-32), that declares another encoding, or |
| 2523: | * that does not start with `<` (EBCDIC, among others). The parsed document is refused too |
| 2524: | * if it has a document type node, should the check above miss one. |
| 2525: | * |
| 2526: | * The document is parsed with `LIBXML_NONET`: the parser never accesses |
| 2527: | * the network. Any libxml error refuses the document, and none is output. |
| 2528: | * Whitespace is preserved: digests are computed over the document as it |
| 2529: | * was signed. |
| 2530: | * |
| 2531: | * @param string $xml The XML document. |
| 2532: | * |
| 2533: | * @param string $what What the document is, for the error messages. |
| 2534: | * |
| 2535: | * @return DOMDocument The loaded document. |
| 2536: | * |
| 2537: | * @throws Exception If the document is refused. |
| 2538: | */ |
| 2539: | static private function _loadXML($xml, $what) |
| 2540: | { |
| 2541: | if (!is_string($xml) || '' === $xml) |
| 2542: | { |
| 2543: | throw new Exception($what . " is not a valid XML document!"); |
| 2544: | } |
| 2545: | |
| 2546: | $start = ltrim(preg_replace('/\A\xEF\xBB\xBF/', '', $xml), " \t\r\n"); |
| 2547: | if (false !== strpos($xml, "\0") |
| 2548: | || (preg_match('/\A<\?xml\s[^>]*?\bencoding\s*=\s*(["\'])([^"\']*)\1/', $start, $matches) |
| 2549: | && 0 !== strcasecmp($matches[2], 'UTF-8'))) |
| 2550: | { |
| 2551: | throw new Exception($what . " must be encoded in UTF-8!"); |
| 2552: | } |
| 2553: | if ('<' !== substr($start, 0, 1)) |
| 2554: | { |
| 2555: | throw new Exception($what . " is not a valid XML document!"); |
| 2556: | } |
| 2557: | |
| 2558: | if (false !== strpos($xml, '<!DOCTYPE')) |
| 2559: | { |
| 2560: | throw new Exception($what . " must not have a document type declaration (DOCTYPE)!"); |
| 2561: | } |
| 2562: | |
| 2563: | $xmlDoc = new DOMDocument('1.0', 'UTF-8'); |
| 2564: | $xmlDoc->preserveWhiteSpace = true; |
| 2565: | |
| 2566: | $prevState = libxml_use_internal_errors(true); |
| 2567: | libxml_clear_errors(); |
| 2568: | try |
| 2569: | { |
| 2570: | $loaded = $xmlDoc->loadXML($xml, LIBXML_NONET); |
| 2571: | $isValidXml = $loaded && 0 === count(libxml_get_errors()); |
| 2572: | } |
| 2573: | finally |
| 2574: | { |
| 2575: | libxml_clear_errors(); |
| 2576: | libxml_use_internal_errors($prevState); |
| 2577: | } |
| 2578: | |
| 2579: | if (!$isValidXml) |
| 2580: | { |
| 2581: | throw new Exception($what . " is not a valid XML document!"); |
| 2582: | } |
| 2583: | |
| 2584: | foreach ($xmlDoc->childNodes as $node) |
| 2585: | { |
| 2586: | if (XML_DOCUMENT_TYPE_NODE === $node->nodeType) |
| 2587: | { |
| 2588: | throw new Exception($what . " must not have a document type declaration (DOCTYPE)!"); |
| 2589: | } |
| 2590: | } |
| 2591: | |
| 2592: | return $xmlDoc; |
| 2593: | } |
| 2594: | |
| 2595: | |
| 2596: | /** |
| 2597: | * Decrypt the content of the node "ds:Object" in place. |
| 2598: | * |
| 2599: | * The failures that do not depend on the token content (no decryption |
| 2600: | * key, a key that cannot be loaded) or only on its structure have their |
| 2601: | * own message. Every failure that depends on the decrypted content |
| 2602: | * (session key, token data, parsing of the decrypted XML) gives the same |
| 2603: | * error, `DECRYPTION_FAILED`, without its reason, and raises no PHP |
| 2604: | * warning: a message or a warning per failure would tell an attacker who |
| 2605: | * can submit altered tokens which step failed, which is how padding |
| 2606: | * oracles (Bleichenbacher on RSA 1.5, Jager-Somorovsky on CBC) decrypt a |
| 2607: | * token step by step. `_readXML()` only calls this function once the |
| 2608: | * digest, the signature, the certificate and the algorithms of the token |
| 2609: | * have been verified. |
| 2610: | * |
| 2611: | * @param DOMElement $encryptedDataNode The node "xenc:EncryptedData", |
| 2612: | * child of the digested node "ds:Signature/ds:Object". |
| 2613: | * |
| 2614: | * @throws Exception |
| 2615: | */ |
| 2616: | private function _decryptToken($encryptedDataNode) |
| 2617: | { |
| 2618: | // Check that we have a private key to perform decryption. |
| 2619: | if (is_null($this->cryptKey)) |
| 2620: | { |
| 2621: | // ERROR: No private key provided for decryption. |
| 2622: | throw new Exception("Token is encrypted but no private key is provided for decryption!"); |
| 2623: | } |
| 2624: | |
| 2625: | // Passes the node containing the ciphered token to an XMLSecEnc |
| 2626: | // object, and indicates to it what is encrypted (the node element or |
| 2627: | // only its content) by reading the attribute "Type" of this node. |
| 2628: | $objXMLSecEnc = new XMLSecEnc(); |
| 2629: | $objXMLSecEnc->setNode($encryptedDataNode); |
| 2630: | $objXMLSecEnc->type = $encryptedDataNode->getAttribute("Type"); |
| 2631: | |
| 2632: | // Get an XMLSecurityKey object already configured with the algorithm |
| 2633: | // which was used to encrypt the token data with the session key. |
| 2634: | $objKeyCrypt = $objXMLSecEnc->locateKey(); |
| 2635: | if (is_null($objKeyCrypt)) |
| 2636: | { |
| 2637: | // ERROR: The algorithm cannot be found. |
| 2638: | throw new Exception("Cannot locate session key ciphering algorithm!"); |
| 2639: | } |
| 2640: | |
| 2641: | // Retrieve the encrypted session key. |
| 2642: | $objKeyInfoCrypt = $objXMLSecEnc->locateKeyInfo($objKeyCrypt); |
| 2643: | if (is_null($objKeyInfoCrypt)) |
| 2644: | { |
| 2645: | // ERROR: Session key cannot be found. |
| 2646: | throw new Exception("Cannot locate session key!"); |
| 2647: | } |
| 2648: | if (!$objKeyInfoCrypt->isEncrypted) |
| 2649: | { |
| 2650: | // ERROR: Session key found but it is not encrypted. |
| 2651: | throw new Exception("Session key is not encrypted!"); |
| 2652: | } |
| 2653: | |
| 2654: | // Loads the private key to be used for deciphering the encrypted |
| 2655: | // session key. If a password is provided, use it to access the |
| 2656: | // private key. |
| 2657: | if (!is_null($this->cryptKeyPassword)) |
| 2658: | { |
| 2659: | $objKeyInfoCrypt->passphrase = $this->cryptKeyPassword; |
| 2660: | } |
| 2661: | $objKeyInfoCrypt->loadKey($this->cryptKey, false); |
| 2662: | if (empty($objKeyInfoCrypt->key)) |
| 2663: | { |
| 2664: | // ERROR: Failed loading private key to decrypt session key! |
| 2665: | throw new Exception("Failed loading private key to decrypt session key!"); |
| 2666: | } |
| 2667: | |
| 2668: | // Decipher the session key, then the token data with it, in place. |
| 2669: | // From here on, every failure gives the same error: exceptions are |
| 2670: | // caught, libxml errors are kept internal and PHP warnings are |
| 2671: | // intercepted (see above). |
| 2672: | $failed = false; |
| 2673: | $prevState = libxml_use_internal_errors(true); |
| 2674: | libxml_clear_errors(); |
| 2675: | set_error_handler(function () use (&$failed) { |
| 2676: | $failed = true; |
| 2677: | return true; |
| 2678: | }, E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED); |
| 2679: | try |
| 2680: | { |
| 2681: | $sessionKey = $objKeyInfoCrypt->encryptedCtx->decryptKey($objKeyInfoCrypt); |
| 2682: | $objKeyCrypt->loadKey($sessionKey); |
| 2683: | $objXMLSecEnc->decryptNode($objKeyCrypt, true); |
| 2684: | if (count(libxml_get_errors()) > 0) |
| 2685: | { |
| 2686: | $failed = true; |
| 2687: | } |
| 2688: | } |
| 2689: | catch (\Throwable $e) |
| 2690: | { |
| 2691: | $failed = true; |
| 2692: | } |
| 2693: | finally |
| 2694: | { |
| 2695: | restore_error_handler(); |
| 2696: | libxml_clear_errors(); |
| 2697: | libxml_use_internal_errors($prevState); |
| 2698: | } |
| 2699: | |
| 2700: | if ($failed) |
| 2701: | { |
| 2702: | // ERROR: Token decryption failed. |
| 2703: | throw new Exception(self::DECRYPTION_FAILED); |
| 2704: | } |
| 2705: | } |
| 2706: | |
| 2707: | |
| 2708: | /** |
| 2709: | * Verify that the X.509 certificate included in the XML token is trusted. |
| 2710: | * |
| 2711: | * The signature of the token is verified with the certificate the token |
| 2712: | * carries: any key pair can produce a valid one, with a certificate that |
| 2713: | * copies the names of a legitimate one. The token is only valid if the |
| 2714: | * certificate is also trusted: |
| 2715: | * |
| 2716: | * - A trust anchor is required: CA certificates (option `caCertPath`) |
| 2717: | * and/or a pinned certificate (option `signerCertPath`). Without it, no |
| 2718: | * certificate is trusted. |
| 2719: | * |
| 2720: | * - The certificate must be valid at the current date and its key usage |
| 2721: | * must allow signing (see `X509Cert::canSign()`). |
| 2722: | * |
| 2723: | * - If a certificate is pinned, the certificate must be this very |
| 2724: | * certificate (same fingerprint). |
| 2725: | * |
| 2726: | * - If CA certificates are given, OpenSSL must verify the chain of the |
| 2727: | * certificate up to them (see `X509Cert::isTrustedBy()`). Only the |
| 2728: | * signing certificate is taken from the token: the token cannot supply |
| 2729: | * intermediate certificates. |
| 2730: | * |
| 2731: | * - If a subject is expected (option `expectedSubject`), the subject of |
| 2732: | * the certificate must match it. |
| 2733: | * |
| 2734: | * Sets the flag returned by `isCertTrusted()`. |
| 2735: | * |
| 2736: | * @throws Exception If the certificate is not trusted, with the reason. |
| 2737: | */ |
| 2738: | private function _verifyCertTrust() |
| 2739: | { |
| 2740: | $this->isCertTrusted = false; |
| 2741: | $cert = $this->x509Certificate; |
| 2742: | |
| 2743: | if (is_null($this->trustedCACertPaths) && is_null($this->pinnedSignerCert)) |
| 2744: | { |
| 2745: | throw new Exception("No trust anchor! Pass the option 'caCertPath' (trusted CA certificates) and/or 'signerCertPath' (pinned signing certificate) to analyze a token: without them, no signing certificate can be trusted."); |
| 2746: | } |
| 2747: | |
| 2748: | if ($cert->isOutOfDate()) |
| 2749: | { |
| 2750: | throw new Exception("Signing certificate is not valid at the current date (valid from " . $cert->getValidFrom(self::TOKEN_TIMESTAMP_FORMAT) . " to " . $cert->getValidTo(self::TOKEN_TIMESTAMP_FORMAT) . ")!"); |
| 2751: | } |
| 2752: | |
| 2753: | if (!$cert->canSign()) |
| 2754: | { |
| 2755: | throw new Exception("Signing certificate is not allowed to sign (key usage)!"); |
| 2756: | } |
| 2757: | |
| 2758: | if (!is_null($this->pinnedSignerCert)) |
| 2759: | { |
| 2760: | $pinned = new X509Cert($this->pinnedSignerCert); |
| 2761: | if (!hash_equals($pinned->getFingerprint(), $cert->getFingerprint())) |
| 2762: | { |
| 2763: | throw new Exception("Signing certificate is not the pinned certificate!"); |
| 2764: | } |
| 2765: | } |
| 2766: | |
| 2767: | if (!is_null($this->trustedCACertPaths) && !$cert->isTrustedBy($this->trustedCACertPaths)) |
| 2768: | { |
| 2769: | throw new Exception("Signing certificate is not issued by a trusted CA: its chain could not be verified up to the CA certificates of the option 'caCertPath', which must hold the issuing CA and the certificates up to its root!"); |
| 2770: | } |
| 2771: | |
| 2772: | if (!is_null($this->expectedSubject)) |
| 2773: | { |
| 2774: | $matches = is_string($this->expectedSubject) |
| 2775: | ? $cert->getDN() === $this->expectedSubject |
| 2776: | : self::_matchesName($cert->getSubject(), $this->expectedSubject); |
| 2777: | if (!$matches) |
| 2778: | { |
| 2779: | throw new Exception("Subject of the signing certificate is not the expected one! Subject: " . $cert->getDN() . "."); |
| 2780: | } |
| 2781: | } |
| 2782: | |
| 2783: | $this->isCertTrusted = true; |
| 2784: | } |
| 2785: | |
| 2786: | |
| 2787: | /** |
| 2788: | * Verify that the token is addressed to the expected audience. |
| 2789: | * |
| 2790: | * A token signed by a trusted issuer is valid for every receiver that |
| 2791: | * trusts this issuer: without an audience, a token issued for one |
| 2792: | * receiver is accepted by another. A token may therefore name the |
| 2793: | * receivers it is addressed to (option "audience" of its creation), and |
| 2794: | * the analysis then requires the receiver to say who it is (option |
| 2795: | * "expectedAudience"): |
| 2796: | * |
| 2797: | * - A token that names audiences is rejected unless `expectedAudience` |
| 2798: | * is given and is one of them. Accepting it without this option would |
| 2799: | * let any receiver of the issuer accept a token that is not addressed |
| 2800: | * to it. |
| 2801: | * |
| 2802: | * - A token that names no audience is rejected when `expectedAudience` is |
| 2803: | * given: the receiver asked for a token addressed to it. |
| 2804: | * |
| 2805: | * - A token that names no audience is accepted when `expectedAudience` is |
| 2806: | * not given, as are the tokens of version 1.0, which have none. |
| 2807: | * |
| 2808: | * When the object has just created the token, the audiences read must be |
| 2809: | * those it wrote. |
| 2810: | * |
| 2811: | * @param boolean $isCreation TRUE if the object has just created the |
| 2812: | * token, FALSE if it analyzes a given one. |
| 2813: | * |
| 2814: | * @throws Exception If the token is not addressed to the expected |
| 2815: | * audience, with the reason. |
| 2816: | */ |
| 2817: | private function _verifyAudience($isCreation) |
| 2818: | { |
| 2819: | $audiences = is_null($this->audience) ? [] : $this->audience; |
| 2820: | |
| 2821: | if ($isCreation) |
| 2822: | { |
| 2823: | if ($audiences !== (is_null($this->audienceToWrite) ? [] : $this->audienceToWrite)) |
| 2824: | { |
| 2825: | throw new Exception("Token audience is not the one written!"); |
| 2826: | } |
| 2827: | return; |
| 2828: | } |
| 2829: | |
| 2830: | if (is_null($this->expectedAudience)) |
| 2831: | { |
| 2832: | if (!empty($audiences)) |
| 2833: | { |
| 2834: | throw new Exception("Token is addressed to an audience ('" . implode("', '", $audiences) . "'), but the option 'expectedAudience' is not given: pass the audience of this receiver to accept it!"); |
| 2835: | } |
| 2836: | return; |
| 2837: | } |
| 2838: | |
| 2839: | if (empty($audiences)) |
| 2840: | { |
| 2841: | throw new Exception("Token is not addressed to any audience, but the audience '" . $this->expectedAudience . "' is expected!"); |
| 2842: | } |
| 2843: | |
| 2844: | if (!in_array($this->expectedAudience, $audiences, true)) |
| 2845: | { |
| 2846: | throw new Exception("Token is not addressed to the expected audience! Expected: '" . $this->expectedAudience . "'. Token audience: '" . implode("', '", $audiences) . "'."); |
| 2847: | } |
| 2848: | } |
| 2849: | |
| 2850: | |
| 2851: | /** |
| 2852: | * Indicates whether a name (the subject or the issuer of a certificate, |
| 2853: | * as returned by `openssl_x509_parse()`) holds the expected fields. |
| 2854: | * |
| 2855: | * Each expected field must be present with the very same value: character |
| 2856: | * case is significant, and a field repeated in the name (e.g. two "OU") |
| 2857: | * is an array of its values. |
| 2858: | * |
| 2859: | * @param array $name The name to check. |
| 2860: | * |
| 2861: | * @param array $expected The expected fields. |
| 2862: | * |
| 2863: | * @return boolean TRUE if every expected field matches, FALSE otherwise. |
| 2864: | */ |
| 2865: | static private function _matchesName($name, $expected) |
| 2866: | { |
| 2867: | foreach ($expected as $key => $value) |
| 2868: | { |
| 2869: | if (!isset($name[$key]) || $name[$key] !== $value) |
| 2870: | { |
| 2871: | return false; |
| 2872: | } |
| 2873: | } |
| 2874: | |
| 2875: | return true; |
| 2876: | } |
| 2877: | |
| 2878: | |
| 2879: | /** |
| 2880: | * Throw an exception whose message lists the anomalies detected during |
| 2881: | * the analysis of the XML token. |
| 2882: | * |
| 2883: | * @throws Exception |
| 2884: | */ |
| 2885: | private function _throwAnomalies() |
| 2886: | { |
| 2887: | $nb = count($this->anomalies); |
| 2888: | throw new Exception($nb . ($nb === 1 ? " anomaly" : " anomalies") . " detected: " . implode(" ; ", $this->anomalies)); |
| 2889: | } |
| 2890: | |
| 2891: | |
| 2892: | /** |
| 2893: | * Indicates whether XML token signature is valid or not. |
| 2894: | * |
| 2895: | * This means that: |
| 2896: | * |
| 2897: | * - No error occurred during the analysis of the XML digital signature. |
| 2898: | * |
| 2899: | * - No anomaly detected during the analysis of the XML digital signature. |
| 2900: | * |
| 2901: | * - The token hash has been verified and is valid. |
| 2902: | * |
| 2903: | * - The token hash signature has been verified and is valid. |
| 2904: | * |
| 2905: | * - The X.509 certificate included in the XML token is trusted: it is |
| 2906: | * valid at the current date, it is the pinned certificate and/or its |
| 2907: | * chain has been verified up to the trusted CA certificates, and its |
| 2908: | * subject is the expected one, if any (see `isCertTrusted()`). |
| 2909: | * |
| 2910: | * - The token is addressed to the audience given by the option |
| 2911: | * `expectedAudience`, if it names audiences or if this option is given |
| 2912: | * (see `getAudience()`). |
| 2913: | * |
| 2914: | * Without a trust anchor given to the analysis, it returns FALSE. |
| 2915: | * |
| 2916: | * **It checks neither the freshness of the token nor whether it is |
| 2917: | * replayed.** A token that was valid once stays valid here, however old |
| 2918: | * it is and however many times it is submitted. Before trusting the data |
| 2919: | * of a received token, the caller must check that: |
| 2920: | * |
| 2921: | * - `isSignatureValid()` returns TRUE; |
| 2922: | * |
| 2923: | * - `isOutOfDate($ttl)` returns FALSE, for the time to live the |
| 2924: | * application accepts (it also checks the expiry of the token, if |
| 2925: | * any); |
| 2926: | * |
| 2927: | * - the identifier returned by `getTokenId()` has not been accepted |
| 2928: | * before: store it atomically, and refuse the token if it already is. |
| 2929: | * |
| 2930: | * The trust anchor `caCertPath` accepts any certificate its CA issues: if |
| 2931: | * this CA also issues certificates to others than the expected sender, |
| 2932: | * any of them can sign a token that passes this check. Restrict it then |
| 2933: | * to the sender with the option `expectedSubject`, or pin its certificate |
| 2934: | * with `signerCertPath`. |
| 2935: | * |
| 2936: | * For a token created encrypted without the private key of the |
| 2937: | * recipient, it tells whether the checks made without decrypting the |
| 2938: | * token passed (see `createSecureXMLToken()`). |
| 2939: | * |
| 2940: | * @return boolean TRUE if XML token is valid, FALSE otherwise. |
| 2941: | */ |
| 2942: | public function isSignatureValid() |
| 2943: | { |
| 2944: | return true === $this->isDigestValueOk |
| 2945: | && true === $this->isSignatureValueOk |
| 2946: | && true === $this->isCertTrusted |
| 2947: | && is_null($this->error); |
| 2948: | } |
| 2949: | |
| 2950: | |
| 2951: | /** |
| 2952: | * Indicates whether the X.509 certificate included in the XML token is |
| 2953: | * trusted or not, according to the trust anchor given to the analysis |
| 2954: | * (options `caCertPath`, `signerCertPath` and `expectedSubject`). |
| 2955: | * |
| 2956: | * NOTE: If not evaluated (because an error occurred before), this function |
| 2957: | * returns NULL. When it returns FALSE, `getError()` gives the reason. |
| 2958: | * |
| 2959: | * @return boolean|null TRUE if the certificate is trusted, FALSE |
| 2960: | * otherwise, NULL if cannot be evaluated. |
| 2961: | */ |
| 2962: | public function isCertTrusted() |
| 2963: | { |
| 2964: | return $this->isCertTrusted; |
| 2965: | } |
| 2966: | |
| 2967: | |
| 2968: | /** |
| 2969: | * Indicates whether the XML token is out of date or not. |
| 2970: | * |
| 2971: | * The token is out of date if its timestamp is more than `$ttl` seconds |
| 2972: | * in the past, or more than `DESYNC_TOLERANCE` seconds in the future |
| 2973: | * (clocks of sender and receiver out of sync), or if it has no valid |
| 2974: | * timestamp. A token created with the option `validity` is also out of |
| 2975: | * date once its expiry is passed (see `getExpiry()`): the shorter of the |
| 2976: | * validity set by the issuer and the `$ttl` set by the receiver applies. |
| 2977: | * |
| 2978: | * The expiry is only checked here, with the timestamp: like the time to |
| 2979: | * live, it is a check left to the caller, and `isSignatureValid()` does |
| 2980: | * not make it. `DESYNC_TOLERANCE` does not apply to the expiry: a |
| 2981: | * receiver whose clock is ahead rejects the token a little earlier. |
| 2982: | * Version 1.0 of this class ignores the expiry: a receiver that has not |
| 2983: | * upgraded only applies its own `$ttl`. |
| 2984: | * |
| 2985: | * NOTE: A token that is not out of date can still be a replayed one: see |
| 2986: | * `getTokenId()`. |
| 2987: | * |
| 2988: | * @param int $ttl The time to live (in seconds) allowed for the token. |
| 2989: | * |
| 2990: | * @return boolean TRUE if token is out of date, FALSE otherwise. |
| 2991: | */ |
| 2992: | public function isOutOfDate($ttl = self::TOKEN_DEFAULT_TTL) |
| 2993: | { |
| 2994: | if (is_null($this->timestamp)) |
| 2995: | { |
| 2996: | // Timestamp is missing! So we assume token is expired. |
| 2997: | return true; |
| 2998: | } |
| 2999: | |
| 3000: | // We will compare date using UTC timezone. |
| 3001: | $timezone = new DateTimeZone('UTC'); |
| 3002: | |
| 3003: | // Compute the UTC UNIX timestamp from the token timestamp string. |
| 3004: | $tokenUTCDate = DateTime::createFromFormat(self::TOKEN_TIMESTAMP_FORMAT, $this->timestamp, $timezone); |
| 3005: | if (false === $tokenUTCDate) |
| 3006: | { |
| 3007: | // Token timestamp format is incorrect! So we assume token is |
| 3008: | // expired. |
| 3009: | return true; |
| 3010: | } |
| 3011: | $tokenUTCTimestamp = $tokenUTCDate->getTimestamp(); |
| 3012: | |
| 3013: | // Compute the UTC UNIX timestamp for the current date/time. |
| 3014: | $curUTCDate = new DateTime('now', $timezone); |
| 3015: | $curUTCTimestamp = $curUTCDate->getTimestamp(); |
| 3016: | |
| 3017: | // A token whose expiry is passed is out of date, whatever $ttl. So |
| 3018: | // is a token whose expiry could not be read: it is not valid either |
| 3019: | // (see isSignatureValid()), but a caller that only calls this |
| 3020: | // function must not take it for a token without expiry. |
| 3021: | if ($this->hasExpiryNode && is_null($this->expiry)) |
| 3022: | { |
| 3023: | return true; |
| 3024: | } |
| 3025: | if (!is_null($this->expiry)) |
| 3026: | { |
| 3027: | $tokenUTCExpiry = DateTime::createFromFormat(self::TOKEN_TIMESTAMP_FORMAT, $this->expiry, $timezone); |
| 3028: | if (false === $tokenUTCExpiry || $tokenUTCExpiry->getTimestamp() < $curUTCTimestamp) |
| 3029: | { |
| 3030: | return true; |
| 3031: | } |
| 3032: | } |
| 3033: | |
| 3034: | // Return timestamps comparison result. |
| 3035: | return $tokenUTCTimestamp > ($curUTCTimestamp + self::DESYNC_TOLERANCE) || ($tokenUTCTimestamp + $ttl) < $curUTCTimestamp; |
| 3036: | } |
| 3037: | |
| 3038: | |
| 3039: | /** |
| 3040: | * Indicates whether token data is encrypted or not. |
| 3041: | * |
| 3042: | * For a token created encrypted, it returns TRUE, whether or not the |
| 3043: | * private key of the recipient was given (see `createSecureXMLToken()`). |
| 3044: | * |
| 3045: | * NOTE: If not evaluated (because an error occurred before), this function |
| 3046: | * returns NULL. |
| 3047: | * |
| 3048: | * @return boolean|null TRUE if token data is encrypted, FALSE otherwise, |
| 3049: | * NULL if cannot be evaluated. |
| 3050: | */ |
| 3051: | public function isDataEncrypted() |
| 3052: | { |
| 3053: | return $this->isDataEncrypted; |
| 3054: | } |
| 3055: | |
| 3056: | |
| 3057: | /** |
| 3058: | * Indicates whether the digest of token data (a hash) has been verified and |
| 3059: | * is valid or not. |
| 3060: | * |
| 3061: | * NOTE: If not evaluated (because an error occurred before), this function |
| 3062: | * returns NULL. |
| 3063: | * |
| 3064: | * @return boolean|null TRUE if token hash has been verified and is valid, |
| 3065: | * FALSE otherwise, NULL if cannot be evaluated. |
| 3066: | */ |
| 3067: | public function isDigestValueOk() |
| 3068: | { |
| 3069: | return $this->isDigestValueOk; |
| 3070: | } |
| 3071: | |
| 3072: | |
| 3073: | /** |
| 3074: | * Indicates whether signature of the digest of the token data has been |
| 3075: | * verified and is valid or not. |
| 3076: | * |
| 3077: | * NOTE: If not evaluated (because an error occurred before), this function |
| 3078: | * returns NULL. |
| 3079: | * |
| 3080: | * @return boolean|null TRUE if token hash signature has been verified and |
| 3081: | * is valid, FALSE otherwise, NULL if cannot be evaluated. |
| 3082: | */ |
| 3083: | public function isSignatureValueOk() |
| 3084: | { |
| 3085: | return $this->isSignatureValueOk; |
| 3086: | } |
| 3087: | |
| 3088: | |
| 3089: | /** |
| 3090: | * Get the XML token. |
| 3091: | * |
| 3092: | * @return string|null The XML token, NULL if not available. |
| 3093: | */ |
| 3094: | public function getXML() |
| 3095: | { |
| 3096: | return $this->xml; |
| 3097: | } |
| 3098: | |
| 3099: | |
| 3100: | /** |
| 3101: | * Get the user data contained in the XML token. |
| 3102: | * |
| 3103: | * The data can only be trusted if `isSignatureValid()` returns TRUE (and |
| 3104: | * the token is neither out of date nor replayed). They are only read |
| 3105: | * once the digest, the signature and the signing certificate have been |
| 3106: | * verified. |
| 3107: | * |
| 3108: | * A token created encrypted without the private key of the recipient is |
| 3109: | * not decrypted: its data are not available (see |
| 3110: | * `createSecureXMLToken()`). |
| 3111: | * |
| 3112: | * @return array|null The associative array containing token data, NULL if |
| 3113: | * not available. |
| 3114: | */ |
| 3115: | public function getData() |
| 3116: | { |
| 3117: | return $this->data; |
| 3118: | } |
| 3119: | |
| 3120: | |
| 3121: | /** |
| 3122: | * Get the timestamp of the XML token. |
| 3123: | * |
| 3124: | * The timestamp of a token that is not valid is not given: nothing read |
| 3125: | * from such a token can be trusted. `isOutOfDate()` still reads it, and |
| 3126: | * rejects such a token anyway when its timestamp could not be read. |
| 3127: | * |
| 3128: | * @return string|null The token timestamp, NULL if the token is not |
| 3129: | * valid (see `isSignatureValid()`), or if it was created encrypted |
| 3130: | * without the private key of the recipient, which leaves its content |
| 3131: | * unread (see `createSecureXMLToken()`). |
| 3132: | */ |
| 3133: | public function getTimestamp() |
| 3134: | { |
| 3135: | return $this->isSignatureValid() ? $this->timestamp : null; |
| 3136: | } |
| 3137: | |
| 3138: | |
| 3139: | /** |
| 3140: | * Get the identifier of the XML token. |
| 3141: | * |
| 3142: | * Each token created by this class holds a random identifier of 128 bits |
| 3143: | * (32 hexadecimal characters), generated with `random_bytes()` and |
| 3144: | * covered by the signature. A valid token can be submitted again for as |
| 3145: | * long as `isOutOfDate()` accepts it: the class keeps no list of the |
| 3146: | * tokens it has analyzed, so the caller must refuse a token whose |
| 3147: | * identifier it has already accepted. A token is accepted from |
| 3148: | * `DESYNC_TOLERANCE` seconds before its timestamp to `$ttl` seconds after |
| 3149: | * it (see `isOutOfDate()`): keep each accepted identifier for at least |
| 3150: | * `$ttl + XMLDSigToken::DESYNC_TOLERANCE` seconds after accepting it. |
| 3151: | * |
| 3152: | * A token without identifier (as created by the 0.x versions) is not |
| 3153: | * valid. |
| 3154: | * |
| 3155: | * @return string|null The token identifier, NULL if the token is not |
| 3156: | * valid (see `isSignatureValid()`), or if it was created encrypted |
| 3157: | * without the private key of the recipient, which leaves its content |
| 3158: | * unread (see `createSecureXMLToken()`). |
| 3159: | */ |
| 3160: | public function getTokenId() |
| 3161: | { |
| 3162: | return $this->isSignatureValid() ? $this->tokenId : null; |
| 3163: | } |
| 3164: | |
| 3165: | |
| 3166: | /** |
| 3167: | * Get the audiences the XML token is addressed to. |
| 3168: | * |
| 3169: | * A token created with the option `audience` names the receivers it is |
| 3170: | * addressed to, and `isSignatureValid()` only accepts it if the analysis |
| 3171: | * gives one of them with the option `expectedAudience` (see |
| 3172: | * `__construct()`). |
| 3173: | * |
| 3174: | * @return array|null The list of the audiences of the token, NULL if the |
| 3175: | * token is not valid (see `isSignatureValid()`), if it names no |
| 3176: | * audience, or if it was created encrypted without the private key of |
| 3177: | * the recipient, which leaves its content unread (see |
| 3178: | * `createSecureXMLToken()`). |
| 3179: | */ |
| 3180: | public function getAudience() |
| 3181: | { |
| 3182: | return $this->isSignatureValid() ? $this->audience : null; |
| 3183: | } |
| 3184: | |
| 3185: | |
| 3186: | /** |
| 3187: | * Get the expiry of the XML token. |
| 3188: | * |
| 3189: | * A token created with the option `validity` holds the date after which |
| 3190: | * it is out of date (see `isOutOfDate()`), in the format of the timestamp |
| 3191: | * (`TOKEN_TIMESTAMP_FORMAT`, UTC). |
| 3192: | * |
| 3193: | * @return string|null The token expiry, NULL if the token is not valid |
| 3194: | * (see `isSignatureValid()`), if it has no expiry, or if it was |
| 3195: | * created encrypted without the private key of the recipient, which |
| 3196: | * leaves its content unread (see `createSecureXMLToken()`). |
| 3197: | */ |
| 3198: | public function getExpiry() |
| 3199: | { |
| 3200: | return $this->isSignatureValid() ? $this->expiry : null; |
| 3201: | } |
| 3202: | |
| 3203: | |
| 3204: | /** |
| 3205: | * Get the message of the error that occurred while parsing the XML token. |
| 3206: | * |
| 3207: | * @return string|null The error message. If no error, NULL is returned. |
| 3208: | */ |
| 3209: | public function getError() |
| 3210: | { |
| 3211: | return $this->error; |
| 3212: | } |
| 3213: | |
| 3214: | |
| 3215: | /** |
| 3216: | * Get the list of anomalies that occurred while parsing the XML token. |
| 3217: | * |
| 3218: | * @return array The list of anomalies, empty if none. |
| 3219: | */ |
| 3220: | public function getAnomalies() |
| 3221: | { |
| 3222: | return $this->anomalies; |
| 3223: | } |
| 3224: | |
| 3225: | |
| 3226: | /** |
| 3227: | * Get the X.509 certificate that is included in the XML token (PEM format). |
| 3228: | * |
| 3229: | * @return string|null The content of the X.509 certificate, NULL if not |
| 3230: | * available. |
| 3231: | */ |
| 3232: | public function getCertificate() |
| 3233: | { |
| 3234: | if (is_null($this->x509Certificate)) |
| 3235: | { |
| 3236: | return null; |
| 3237: | } |
| 3238: | |
| 3239: | return $this->x509Certificate->getPem(); |
| 3240: | } |
| 3241: | |
| 3242: | |
| 3243: | /** |
| 3244: | * Get the issuer information of the X.509 certificate that is included in |
| 3245: | * the XML token. |
| 3246: | * |
| 3247: | * @return array|null The issuer information of the X.509 certificate, NULL |
| 3248: | * if not available. |
| 3249: | */ |
| 3250: | public function getCertIssuer() |
| 3251: | { |
| 3252: | if (is_null($this->x509Certificate)) |
| 3253: | { |
| 3254: | return null; |
| 3255: | } |
| 3256: | |
| 3257: | return $this->x509Certificate->getIssuer(); |
| 3258: | } |
| 3259: | |
| 3260: | |
| 3261: | /** |
| 3262: | * Get the subject information of the X.509 certificate that is included in |
| 3263: | * the XML token. |
| 3264: | * |
| 3265: | * @return array|null The subject information of the X.509 certificate, NULL |
| 3266: | * if not available. |
| 3267: | */ |
| 3268: | public function getCertSubject() |
| 3269: | { |
| 3270: | if (is_null($this->x509Certificate)) |
| 3271: | { |
| 3272: | return null; |
| 3273: | } |
| 3274: | |
| 3275: | return $this->x509Certificate->getSubject(); |
| 3276: | } |
| 3277: | |
| 3278: | |
| 3279: | /** |
| 3280: | * Get the Distinguished Name of the X.509 certificate that is included in |
| 3281: | * the XML token. |
| 3282: | * |
| 3283: | * @return string|null The Distinguished Name of the X.509 certificate, NULL |
| 3284: | * if not available. |
| 3285: | */ |
| 3286: | public function getCertDN() |
| 3287: | { |
| 3288: | if (is_null($this->x509Certificate)) |
| 3289: | { |
| 3290: | return null; |
| 3291: | } |
| 3292: | |
| 3293: | return $this->x509Certificate->getDN(); |
| 3294: | } |
| 3295: | |
| 3296: | |
| 3297: | /** |
| 3298: | * Get the UTC date from which the X.509 certificate that is included in the |
| 3299: | * XML token is valid. |
| 3300: | * |
| 3301: | * @param string $dateFormat The format of the returned date (Default: |
| 3302: | * XMLDSigToken::TOKEN_TIMESTAMP_FORMAT). |
| 3303: | * |
| 3304: | * @return string|null|false The date from which the X.509 certificate is |
| 3305: | * valid, NULL if not available, FALSE if $dateFormat is invalid. |
| 3306: | */ |
| 3307: | public function getCertValidFrom($dateFormat = self::TOKEN_TIMESTAMP_FORMAT) |
| 3308: | { |
| 3309: | if (is_null($this->x509Certificate)) |
| 3310: | { |
| 3311: | return null; |
| 3312: | } |
| 3313: | |
| 3314: | return $this->x509Certificate->getValidFrom($dateFormat); |
| 3315: | } |
| 3316: | |
| 3317: | |
| 3318: | /** |
| 3319: | * Get the UTC date to which the X.509 certificate that is included in the |
| 3320: | * XML token is valid. |
| 3321: | * |
| 3322: | * @param string $dateFormat The format of the returned date (Default: |
| 3323: | * XMLDSigToken::TOKEN_TIMESTAMP_FORMAT). |
| 3324: | * |
| 3325: | * @return string|null|false The date to which the X.509 certificate is |
| 3326: | * valid, NULL if |
| 3327: | * not available, FALSE if $dateFormat is invalid. |
| 3328: | */ |
| 3329: | public function getCertValidTo($dateFormat = self::TOKEN_TIMESTAMP_FORMAT) |
| 3330: | { |
| 3331: | if (is_null($this->x509Certificate)) |
| 3332: | { |
| 3333: | return null; |
| 3334: | } |
| 3335: | |
| 3336: | return $this->x509Certificate->getValidTo($dateFormat); |
| 3337: | } |
| 3338: | |
| 3339: | |
| 3340: | /** |
| 3341: | * Indicates whether the X.509 certificate included in the XML token is out |
| 3342: | * of date or not. |
| 3343: | * |
| 3344: | * @return boolean TRUE if X.509 certificate is out of date, FALSE |
| 3345: | * otherwise. |
| 3346: | */ |
| 3347: | public function isCertOutOfDate() |
| 3348: | { |
| 3349: | if (is_null($this->x509Certificate)) |
| 3350: | { |
| 3351: | return true; |
| 3352: | } |
| 3353: | |
| 3354: | return $this->x509Certificate->isOutOfDate(); |
| 3355: | } |
| 3356: | |
| 3357: | |
| 3358: | /** |
| 3359: | * Check that the issuer information of the X.509 certificate that is |
| 3360: | * included in the XML token matches the expected one. |
| 3361: | * |
| 3362: | * The expected issuer information must be passed as an array to the |
| 3363: | * function: |
| 3364: | * |
| 3365: | * ```php |
| 3366: | * $ExpectedIssuerInfo = [ |
| 3367: | * 'C' => 'DK', |
| 3368: | * 'ST' => 'Jylland', |
| 3369: | * 'O' => 'Lothbrok Ltd', |
| 3370: | * 'OU' => 'Jarl Dept', |
| 3371: | * 'CN' => 'www.Lothbrok.dk', |
| 3372: | * 'emailAddress' => 'ragnar@Lothbrok.dk' |
| 3373: | * ]; |
| 3374: | * ``` |
| 3375: | * |
| 3376: | * NOTE: Character case of data is significant. |
| 3377: | * |
| 3378: | * NOTE: This only compares names, which any certificate can copy: it |
| 3379: | * tells nothing about who issued the certificate. `isSignatureValid()` |
| 3380: | * verifies the certificate against the trust anchor of the analysis. |
| 3381: | * |
| 3382: | * @param array $expectedIssuerInfo The issuer information that the X.509 |
| 3383: | * certificate should match. |
| 3384: | * |
| 3385: | * @return boolean TRUE if the issuer of the X.509 certificate holds the |
| 3386: | * expected information, FALSE otherwise. |
| 3387: | * |
| 3388: | * @throws Exception |
| 3389: | */ |
| 3390: | public function isValidCertIssuer($expectedIssuerInfo) |
| 3391: | { |
| 3392: | // Check if the expected issuer information is an array. |
| 3393: | if (!is_array($expectedIssuerInfo)) |
| 3394: | { |
| 3395: | throw new Exception("Invalid parameter 'issuerInfo'! Issuer information should be an array."); |
| 3396: | } |
| 3397: | |
| 3398: | // Verify that the information of the X.509 certificate matches the |
| 3399: | // expected one. |
| 3400: | $certIssuerInfo = $this->getCertIssuer(); |
| 3401: | if (is_null($certIssuerInfo)) |
| 3402: | { |
| 3403: | return false; |
| 3404: | } |
| 3405: | |
| 3406: | return self::_matchesName($certIssuerInfo, $expectedIssuerInfo); |
| 3407: | } |
| 3408: | |
| 3409: | |
| 3410: | /** |
| 3411: | * Check that the X.509 certificate included in the XML token comes from the |
| 3412: | * expected CA. |
| 3413: | * |
| 3414: | * Note that more than one CA certificate can give a positive result, some |
| 3415: | * CAs re-issue signing certificates after having only changed the |
| 3416: | * expiration dates. |
| 3417: | * |
| 3418: | * Note that it also works with self-signed certificates. In this case, |
| 3419: | * pass the X.509 certificate that is supposed to be included in the XML |
| 3420: | * token to the function. |
| 3421: | * |
| 3422: | * Only the link between the certificate and the given CA certificate is |
| 3423: | * checked (see `X509Cert::isValidCA()`). `isSignatureValid()` verifies the |
| 3424: | * whole chain against the trust anchor of the analysis. |
| 3425: | * |
| 3426: | * @param string $caCertPath The PEM certificate (public key) that is |
| 3427: | * supposed to be used by the CA to sign the X.509 certificate included in |
| 3428: | * the XML token. |
| 3429: | * |
| 3430: | * @return boolean TRUE if the X.509 certificate has been signed by the |
| 3431: | * expected CA, FALSE otherwise (and when no certificate has been read |
| 3432: | * from the token). |
| 3433: | * |
| 3434: | * @throws Exception |
| 3435: | */ |
| 3436: | public function isValidCertCA($caCertPath) |
| 3437: | { |
| 3438: | if (is_null($this->x509Certificate)) |
| 3439: | { |
| 3440: | return false; |
| 3441: | } |
| 3442: | |
| 3443: | // Get the CA public key that is supposed to be used to sign the X.509 |
| 3444: | // certificate. |
| 3445: | $caCert = @file_get_contents($caCertPath); |
| 3446: | if (false === $caCert) |
| 3447: | { |
| 3448: | throw new Exception("Cannot read CA certificate to perform signer check! File: " . $caCertPath); |
| 3449: | } |
| 3450: | |
| 3451: | return $this->x509Certificate->isValidCA($caCert); |
| 3452: | } |
| 3453: | |
| 3454: | |
| 3455: | /** |
| 3456: | * Create the XMLSecurityDSig object used to verify an XML token. |
| 3457: | * |
| 3458: | * Digests and signatures are computed over canonicalized XML. If the |
| 3459: | * canonicalization fails, `DOMNode::C14N()` returns FALSE (on PHP 8, some |
| 3460: | * XPath failures throw an Error instead), and an XPath transform that |
| 3461: | * selects nothing yields an empty string. Depending on its version, |
| 3462: | * xmlseclibs then hashes an empty string: a forged reference only has to |
| 3463: | * carry the digest of the empty string to validate, whatever the content |
| 3464: | * of the referenced node. The returned object therefore makes the |
| 3465: | * canonicalization of the node "ds:SignedInfo" and of each referenced node |
| 3466: | * throw an exception unless it produces a non-empty string. |
| 3467: | * |
| 3468: | * This check is deliberately independent of the xmlseclibs version and |
| 3469: | * must not be removed, even if xmlseclibs performs a similar check. |
| 3470: | * |
| 3471: | * @return XMLSecurityDSig The verifier. |
| 3472: | */ |
| 3473: | static private function _createVerifier() |
| 3474: | { |
| 3475: | return new class() extends XMLSecurityDSig |
| 3476: | { |
| 3477: | public function canonicalizeSignedInfo() |
| 3478: | { |
| 3479: | return $this->guard(function () { |
| 3480: | return parent::canonicalizeSignedInfo(); |
| 3481: | }, "ds:SignedInfo"); |
| 3482: | } |
| 3483: | |
| 3484: | public function processTransforms($refNode, $objData, $includeCommentNodes = true) |
| 3485: | { |
| 3486: | return $this->guard(function () use ($refNode, $objData, $includeCommentNodes) { |
| 3487: | return parent::processTransforms($refNode, $objData, $includeCommentNodes); |
| 3488: | }, "referenced node"); |
| 3489: | } |
| 3490: | |
| 3491: | private function guard(callable $canonicalize, $what) |
| 3492: | { |
| 3493: | try |
| 3494: | { |
| 3495: | $data = $canonicalize(); |
| 3496: | } |
| 3497: | catch (\Throwable $e) |
| 3498: | { |
| 3499: | throw new Exception("Canonicalization of " . $what . " failed! Reason: " . $e->getMessage()); |
| 3500: | } |
| 3501: | |
| 3502: | if (!is_string($data) || '' === $data) |
| 3503: | { |
| 3504: | throw new Exception("Canonicalization of " . $what . " failed or is empty!"); |
| 3505: | } |
| 3506: | |
| 3507: | return $data; |
| 3508: | } |
| 3509: | }; |
| 3510: | } |
| 3511: | |
| 3512: | |
| 3513: | /** |
| 3514: | * Check if an algorithm is valid to perform XML canonicalization |
| 3515: | * operations. |
| 3516: | * |
| 3517: | * @param string $algorithm The algorithm reference (URL). |
| 3518: | * |
| 3519: | * @return boolean TRUE if valid, FALSE otherwise. |
| 3520: | */ |
| 3521: | static private function _isValidCanonicalizationAlgorithm($algorithm) |
| 3522: | { |
| 3523: | // switch compares loosely: TRUE would match any algorithm. |
| 3524: | if (!is_string($algorithm)) |
| 3525: | { |
| 3526: | return(false); |
| 3527: | } |
| 3528: | |
| 3529: | switch ($algorithm) |
| 3530: | { |
| 3531: | case (self::C14N): |
| 3532: | case (self::C14N_COMMENTS): |
| 3533: | case (self::EXC_C14N): |
| 3534: | case (self::EXC_C14N_COMMENTS): |
| 3535: | return(true); |
| 3536: | default: |
| 3537: | return(false); |
| 3538: | } |
| 3539: | } |
| 3540: | |
| 3541: | |
| 3542: | /** |
| 3543: | * Check if an algorithm is valid to perform hash operations. |
| 3544: | * |
| 3545: | * @param string $algorithm The algorithm reference (URL). |
| 3546: | * |
| 3547: | * @return boolean TRUE if valid, FALSE otherwise. |
| 3548: | */ |
| 3549: | static private function _isValidHashAlgorithm($algorithm) |
| 3550: | { |
| 3551: | // switch compares loosely: TRUE would match any algorithm. |
| 3552: | if (!is_string($algorithm)) |
| 3553: | { |
| 3554: | return(false); |
| 3555: | } |
| 3556: | |
| 3557: | switch ($algorithm) |
| 3558: | { |
| 3559: | case (self::SHA1): |
| 3560: | case (self::SHA256): |
| 3561: | case (self::SHA384): |
| 3562: | case (self::SHA512): |
| 3563: | case (self::RIPEMD160): |
| 3564: | return(true); |
| 3565: | default: |
| 3566: | return(false); |
| 3567: | } |
| 3568: | } |
| 3569: | |
| 3570: | |
| 3571: | /** |
| 3572: | * Check if an algorithm is valid to sign the node "ds:SignedInfo". |
| 3573: | * |
| 3574: | * Signature and key transport algorithms (see |
| 3575: | * `_isValidKeyTransportAlgorithm()`) are both asymmetric, but each list |
| 3576: | * only holds its own: a key transport algorithm is not a signature |
| 3577: | * algorithm, and the reverse. |
| 3578: | * |
| 3579: | * @param string $algorithm The algorithm reference (URL). |
| 3580: | * |
| 3581: | * @return boolean TRUE if valid, FALSE otherwise. |
| 3582: | */ |
| 3583: | static private function _isValidSignatureAlgorithm($algorithm) |
| 3584: | { |
| 3585: | // switch compares loosely: TRUE would match any algorithm. |
| 3586: | if (!is_string($algorithm)) |
| 3587: | { |
| 3588: | return(false); |
| 3589: | } |
| 3590: | |
| 3591: | switch ($algorithm) |
| 3592: | { |
| 3593: | // case (self::DSA_SHA1): // Does not work. |
| 3594: | case (self::RSA_SHA1): |
| 3595: | case (self::RSA_SHA256): |
| 3596: | case (self::RSA_SHA384): |
| 3597: | case (self::RSA_SHA512): |
| 3598: | // case (self::HMAC_SHA1): // Does not work. |
| 3599: | return(true); |
| 3600: | default: |
| 3601: | return(false); |
| 3602: | } |
| 3603: | } |
| 3604: | |
| 3605: | |
| 3606: | /** |
| 3607: | * Check if an algorithm is valid to encrypt the session key that encrypts |
| 3608: | * the token (key transport). |
| 3609: | * |
| 3610: | * RSA 1.5 (PKCS #1 v1.5 padding) is not valid, neither to create nor to |
| 3611: | * analyze a token: whether the padding of a submitted session key is |
| 3612: | * valid can be told from the decryption (Bleichenbacher), which lets an |
| 3613: | * attacker who can submit altered tokens decrypt a session key. |
| 3614: | * |
| 3615: | * @param string $algorithm The algorithm reference (URL). |
| 3616: | * |
| 3617: | * @return boolean TRUE if valid, FALSE otherwise. |
| 3618: | * |
| 3619: | * @see _isValidSignatureAlgorithm() |
| 3620: | */ |
| 3621: | static private function _isValidKeyTransportAlgorithm($algorithm) |
| 3622: | { |
| 3623: | // switch compares loosely: TRUE would match any algorithm. |
| 3624: | if (!is_string($algorithm)) |
| 3625: | { |
| 3626: | return(false); |
| 3627: | } |
| 3628: | |
| 3629: | switch ($algorithm) |
| 3630: | { |
| 3631: | case (self::RSA_OAEP_MGF1P): |
| 3632: | return(true); |
| 3633: | default: |
| 3634: | return(false); |
| 3635: | } |
| 3636: | } |
| 3637: | |
| 3638: | |
| 3639: | /** |
| 3640: | * Check if an algorithm is valid to perform symmetric ciphering operations. |
| 3641: | * |
| 3642: | * @param string $algorithm The algorithm reference (URL). |
| 3643: | * |
| 3644: | * @return boolean TRUE if valid, FALSE otherwise. |
| 3645: | */ |
| 3646: | static private function _isValidSymmetricCipheringAlgorithm($algorithm) |
| 3647: | { |
| 3648: | // switch compares loosely: TRUE would match any algorithm. |
| 3649: | if (!is_string($algorithm)) |
| 3650: | { |
| 3651: | return(false); |
| 3652: | } |
| 3653: | |
| 3654: | switch ($algorithm) |
| 3655: | { |
| 3656: | case (self::TRIPLEDES_CBC): |
| 3657: | case (self::AES128_CBC): |
| 3658: | case (self::AES192_CBC): |
| 3659: | case (self::AES256_CBC): |
| 3660: | case (self::AES128_GCM): |
| 3661: | case (self::AES192_GCM): |
| 3662: | case (self::AES256_GCM): |
| 3663: | return(true); |
| 3664: | default: |
| 3665: | return(false); |
| 3666: | } |
| 3667: | } |
| 3668: | |
| 3669: | |
| 3670: | /** |
| 3671: | * Check if a symmetric algorithm authenticates the data it decrypts. |
| 3672: | * |
| 3673: | * AES-GCM checks an authentication tag before returning any decrypted |
| 3674: | * data: altered data fails as a whole. CBC has no integrity: altered |
| 3675: | * data is decrypted all the same, and how the decrypted content then |
| 3676: | * fails (padding, XML parsing) can tell an attacker who can submit |
| 3677: | * altered tokens what it holds (Jager-Somorovsky). A token encrypted |
| 3678: | * with CBC is therefore only decrypted if the analysis declares this |
| 3679: | * algorithm (option `sessionKeyCipheringAlgorithm`), whatever |
| 3680: | * `checkCryptingAlgorithms`. |
| 3681: | * |
| 3682: | * @param string $algorithm The algorithm reference (URL). |
| 3683: | * |
| 3684: | * @return boolean TRUE if the algorithm is authenticated, FALSE otherwise. |
| 3685: | */ |
| 3686: | static private function _isAuthenticatedCipheringAlgorithm($algorithm) |
| 3687: | { |
| 3688: | return in_array($algorithm, [self::AES128_GCM, self::AES192_GCM, self::AES256_GCM], true); |
| 3689: | } |
| 3690: | |
| 3691: | |
| 3692: | /** |
| 3693: | * Utility function for modifying a node of an XML content. |
| 3694: | * |
| 3695: | * The XML content is loaded as an XML token is: a document with a |
| 3696: | * document type declaration (DOCTYPE), or not encoded in UTF-8, is |
| 3697: | * refused before it is parsed, and the parser never accesses the network |
| 3698: | * (see `_loadXML()`). A refused or invalid document is left unchanged and |
| 3699: | * the function returns FALSE. |
| 3700: | * |
| 3701: | * @param string $xml XML content, passed by reference. |
| 3702: | * |
| 3703: | * @param string $nodePath The xpath query to select the node to modify. |
| 3704: | * This should be a full path that identifies a single node. |
| 3705: | * |
| 3706: | * @param string $nodeAttribute In case you want to modify an attribute of |
| 3707: | * the specified node, provide its name here, otherwise set this |
| 3708: | * parameter to NULL. |
| 3709: | * |
| 3710: | * @param string $newValue The value that will replace the original value of |
| 3711: | * the node or of the attribute of the node if $nodeAttribute parameter |
| 3712: | * is provided. |
| 3713: | * |
| 3714: | * @param boolean $delete If TRUE delete the specified node or attribute. |
| 3715: | * |
| 3716: | * @return string|false The value of the node or attribute before |
| 3717: | * modification or deletion, FALSE otherwise. |
| 3718: | */ |
| 3719: | static public function alterXML(&$xml, $nodePath, $nodeAttribute, $newValue, $delete = false) |
| 3720: | { |
| 3721: | // Assume modification will fail. |
| 3722: | $oldValue = false; |
| 3723: | |
| 3724: | // Load the XML content into a DOM document. |
| 3725: | try |
| 3726: | { |
| 3727: | $xmlDoc = self::_loadXML($xml, "XML content"); |
| 3728: | } |
| 3729: | catch (Exception $e) |
| 3730: | { |
| 3731: | return false; |
| 3732: | } |
| 3733: | $xmlDoc->formatOutput = false; |
| 3734: | |
| 3735: | // Instantiate a DOMXpath object to navigate in the XML document. |
| 3736: | $xpath = new DOMXPath($xmlDoc); |
| 3737: | $xpath->registerNamespace('ds', XMLSecurityDSig::XMLDSIGNS); |
| 3738: | $xpath->registerNamespace('xenc', XMLSecEnc::XMLENCNS); |
| 3739: | |
| 3740: | // Locate the node. |
| 3741: | $nodeList = $xpath->query($nodePath); |
| 3742: | if (1 <= $nodeList->length) |
| 3743: | { |
| 3744: | // Node found. |
| 3745: | $node = $nodeList->item(0); |
| 3746: | |
| 3747: | if (is_null($nodeAttribute)) |
| 3748: | { |
| 3749: | if ($delete) |
| 3750: | { |
| 3751: | // Delete the node. |
| 3752: | $oldValue = $node->nodeValue; |
| 3753: | $node->parentNode->removeChild($node); |
| 3754: | } |
| 3755: | else |
| 3756: | { |
| 3757: | // Replace node value with the given one. |
| 3758: | $oldValue = $node->nodeValue; |
| 3759: | $node->nodeValue = $newValue; |
| 3760: | } |
| 3761: | } |
| 3762: | else |
| 3763: | { |
| 3764: | if ($node->hasAttribute($nodeAttribute)) |
| 3765: | { |
| 3766: | if ($delete) |
| 3767: | { |
| 3768: | // Delete attribute. |
| 3769: | $oldValue = $node->getAttribute($nodeAttribute); |
| 3770: | $node->removeAttribute($nodeAttribute); |
| 3771: | } |
| 3772: | else |
| 3773: | { |
| 3774: | // Replace attribute value with the given one. |
| 3775: | $oldValue = $node->getAttribute($nodeAttribute); |
| 3776: | $node->removeAttribute($nodeAttribute); |
| 3777: | $node->setAttribute($nodeAttribute, $newValue); |
| 3778: | } |
| 3779: | } |
| 3780: | } |
| 3781: | } |
| 3782: | |
| 3783: | // Update the XML content with its modified version. |
| 3784: | if ($oldValue) |
| 3785: | { |
| 3786: | $xml = $xmlDoc->saveXML(); |
| 3787: | } |
| 3788: | |
| 3789: | // Return the old value of the modified or deleted element (a node or |
| 3790: | // an attribute) or false on failure. |
| 3791: | return $oldValue; |
| 3792: | } |
| 3793: | |
| 3794: | |
| 3795: | /** |
| 3796: | * Return the XML token in a pretty readable format. |
| 3797: | * |
| 3798: | * This function is intended for display purposes only. Use the `getXML()` |
| 3799: | * function to obtain the raw XML token you want to work with. |
| 3800: | * |
| 3801: | * @return string The pretty formatted XMLDSigToken signature. |
| 3802: | */ |
| 3803: | public function getPrettyXML() |
| 3804: | { |
| 3805: | $xml = $this->xml; |
| 3806: | $formatted = ''; |
| 3807: | $pad = 0; |
| 3808: | $indent_size = 2; |
| 3809: | $matches = array(); |
| 3810: | |
| 3811: | // Inline function that splits the value of a node |
| 3812: | // into multiple lines of 64 characters. |
| 3813: | $splitNodeValue = function(&$xml, $prefix, $xpathQuery) |
| 3814: | { |
| 3815: | $xpathQuery = str_replace('ds:', $prefix, $xpathQuery); |
| 3816: | $nodeValue = XMLDSigToken::alterXML($xml, $xpathQuery, null, 'placeholer', false); |
| 3817: | if (false === $nodeValue) |
| 3818: | { |
| 3819: | // No such node, or the XML token is not a valid XML document. |
| 3820: | return; |
| 3821: | } |
| 3822: | $nodeValue = PHP_EOL . implode(PHP_EOL, str_split($nodeValue, 64)) . PHP_EOL; |
| 3823: | XMLDSigToken::alterXML($xml, $xpathQuery, null, $nodeValue, false); |
| 3824: | // Remove XML encoded carriage return introduced when splitting node |
| 3825: | // value. |
| 3826: | $xml = str_replace('
', '', $xml); |
| 3827: | }; |
| 3828: | |
| 3829: | // Split node values that are too long. |
| 3830: | $prefix = $this->xmldsigNsPrefix ? $this->xmldsigNsPrefix . ':' : ''; |
| 3831: | $splitNodeValue($xml, $prefix, '//ds:Signature/ds:SignatureValue'); |
| 3832: | $splitNodeValue($xml, $prefix, '//ds:Signature/ds:KeyInfo/ds:X509Data/ds:X509Certificate'); |
| 3833: | $splitNodeValue($xml, $prefix, '//ds:Signature/ds:Object/xenc:EncryptedData/ds:KeyInfo/xenc:EncryptedKey/xenc:CipherData/xenc:CipherValue'); |
| 3834: | $splitNodeValue($xml, $prefix, '//ds:Signature/ds:Object/xenc:EncryptedData/xenc:CipherData/xenc:CipherValue'); |
| 3835: | |
| 3836: | // Tokenise the XML string. |
| 3837: | $xml = preg_replace('/(>)[\s]*(<)(\/*)/', "$1\n$2$3", $xml); |
| 3838: | |
| 3839: | // Get the first token. |
| 3840: | $tok = strtok($xml, "\n"); |
| 3841: | |
| 3842: | // Scan each token and adjust indent based on opening/closing tags. |
| 3843: | while ($tok !== false) |
| 3844: | { |
| 3845: | // Test for the various tag states |
| 3846: | if (preg_match('/.+<\/\w[^>]*>$/', $tok, $matches)) |
| 3847: | { |
| 3848: | // Opening and closing tags on same line: no change. |
| 3849: | $indent = 0; |
| 3850: | } |
| 3851: | elseif (preg_match('/^<\/\w/', $tok, $matches)) |
| 3852: | { |
| 3853: | // Closing tag: outdent now. |
| 3854: | $pad = $pad - $indent_size; |
| 3855: | } |
| 3856: | elseif (preg_match('/^<\w[^>]*[^\/]>.*$/', $tok, $matches)) |
| 3857: | { |
| 3858: | // Opening tag: don't pad this one, only subsequent tags. |
| 3859: | $indent = $indent_size; |
| 3860: | } |
| 3861: | else |
| 3862: | { |
| 3863: | // No indentation needed. |
| 3864: | $indent = 0; |
| 3865: | } |
| 3866: | |
| 3867: | // Pad the line with the required number of leading spaces. |
| 3868: | $prettyLine = str_pad($tok, strlen($tok) + $pad, ' ', STR_PAD_LEFT); |
| 3869: | $formatted .= $prettyLine . "\n"; |
| 3870: | |
| 3871: | // Get the next token |
| 3872: | $tok = strtok("\n"); |
| 3873: | |
| 3874: | // Update the pad size for subsequent token. |
| 3875: | $pad += $indent; |
| 3876: | } |
| 3877: | |
| 3878: | // Return formatted XML string. |
| 3879: | return $formatted; |
| 3880: | } |
| 3881: | |
| 3882: | |
| 3883: | /** |
| 3884: | * Utility function that returns information on the XML token in HTML |
| 3885: | * format. |
| 3886: | * |
| 3887: | * The output shows what the token carries, valid or not: data, |
| 3888: | * certificate fields and error messages come from whoever sent the token. |
| 3889: | * Every value is therefore escaped with `htmlspecialchars()` |
| 3890: | * (`ENT_QUOTES | ENT_SUBSTITUTE`, UTF-8), the outputs of `var_dump()` |
| 3891: | * included, so that it can be displayed as is in an HTML page. The output |
| 3892: | * loads no external resource (script, style sheet, image). |
| 3893: | * |
| 3894: | * @return string An HTML fragment. |
| 3895: | */ |
| 3896: | public function getHTMLDump() |
| 3897: | { |
| 3898: | // Inline function to escape a value for HTML output. |
| 3899: | $escape = function($value) |
| 3900: | { |
| 3901: | return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); |
| 3902: | }; |
| 3903: | |
| 3904: | // Inline function to output var_dump() into a string. |
| 3905: | $dumpToString = function($var) |
| 3906: | { |
| 3907: | // Capture var_dump() output. When Xdebug overloads var_dump(), it |
| 3908: | // writes HTML if html_errors is on: turn it off so that the |
| 3909: | // output is plain text, which is then escaped like any value. |
| 3910: | $htmlErrors = ini_set('html_errors', '0'); |
| 3911: | ob_start(); |
| 3912: | var_dump($var); |
| 3913: | $res = ob_get_clean(); |
| 3914: | if (false !== $htmlErrors) |
| 3915: | { |
| 3916: | ini_set('html_errors', $htmlErrors); |
| 3917: | } |
| 3918: | |
| 3919: | // Remove the location ("file:line:") Xdebug writes first. |
| 3920: | if (extension_loaded('xdebug')) |
| 3921: | { |
| 3922: | $res = preg_replace('@\A[^\n]*:\d+:\n@', '', $res, 1); |
| 3923: | } |
| 3924: | |
| 3925: | // Return result. |
| 3926: | return $res; |
| 3927: | }; |
| 3928: | |
| 3929: | $results = array(); |
| 3930: | $results['isSignatureValid()'] = $this->isSignatureValid() ? 'TRUE' : 'FALSE'; |
| 3931: | $results['isDataEncrypted()'] = $this->isDataEncrypted() ? 'TRUE' : 'FALSE'; |
| 3932: | $results['getTimestamp()'] = $this->getTimestamp(); |
| 3933: | $results['getTokenId()'] = is_null($this->getTokenId()) ? 'NULL' : $this->getTokenId(); |
| 3934: | $results['getAudience()'] = $dumpToString($this->getAudience()); |
| 3935: | $results['getExpiry()'] = is_null($this->getExpiry()) ? 'NULL' : $this->getExpiry(); |
| 3936: | $results['getData()'] = $dumpToString($this->getData()); |
| 3937: | $results['getError()'] = is_null($this->getError()) ? 'NULL' : $this->getError(); |
| 3938: | $results['getAnomalies()'] = $dumpToString($this->getAnomalies()); |
| 3939: | $results['getCertDN()'] = $this->getCertDN(); |
| 3940: | $results['getCertSubject()'] = $dumpToString($this->getCertSubject()); |
| 3941: | $results['getCertIssuer()'] = $dumpToString($this->getCertIssuer()); |
| 3942: | $results['getCertValidFrom()'] = $this->getCertValidFrom(); |
| 3943: | $results['getCertValidTo()'] = $this->getCertValidTo(); |
| 3944: | $results['isCertOutOfDate()'] = $this->isCertOutOfDate() ? 'TRUE' : 'FALSE'; |
| 3945: | |
| 3946: | $html = '<table cellpadding="5">'; |
| 3947: | $html .= '<tr><td colspan="2"><pre class="xdebug-var-dump"><b><span style="color: red">XML Digital Signature</span></b></pre></td></tr>'; |
| 3948: | $html .= '<tr><td colspan="2"><pre>' . $escape($this->getPrettyXML()) . '</pre></td></tr>'; |
| 3949: | $html .= '<tr><td width="1%"><pre><b><span style="color: red">METHOD CALLS</span></b></pre></td>'; |
| 3950: | $html .= '<td valign="top" width="99%"><pre><b><span style="color: red">RESULTS</span></b></pre></td></tr>'; |
| 3951: | foreach ($results as $key => $value) { |
| 3952: | $html .= '<tr><td valign="top"><pre><b>' . $escape($key) . '</b></pre></td>'; |
| 3953: | $html .= '<td valign="top"><pre>' . $escape($value) . '</pre></td></tr>'; |
| 3954: | } |
| 3955: | $html .= '</table>'; |
| 3956: | |
| 3957: | return $html; |
| 3958: | } |
| 3959: | } |
| 3960: |