key_id = (string) $key_id; $this->issuer_id = (string) $issuer_id; $this->p8 = (string) $p8; } /** * Find the bundle ID resource for $identifier, creating it if absent. * * @param string $identifier e.g. com.example.app. * @param string $name Human label for a newly created one. * * @return string|\WP_Error The bundle ID resource id. */ public function ensure_bundle_id( $identifier, $name ) { $found = $this->request( 'GET', '/v1/bundleIds?filter%5Bidentifier%5D=' . rawurlencode( $identifier ) . '&limit=200' ); if ( is_wp_error( $found ) ) { return $found; } foreach ( (array) ( $found['data'] ?? array() ) as $row ) { if ( isset( $row['attributes']['identifier'] ) && $row['attributes']['identifier'] === $identifier ) { return (string) $row['id']; } } $created = $this->request( 'POST', '/v1/bundleIds', array( 'data' => array( 'type' => 'bundleIds', 'attributes' => array( 'identifier' => $identifier, 'name' => preg_replace( '/[^A-Za-z0-9 ]/', ' ', $name ) ?: 'Esipher App', 'platform' => 'IOS', ), ), ) ); if ( is_wp_error( $created ) ) { return $created; } return (string) ( $created['data']['id'] ?? '' ); } /** * Submit a CSR and get back a distribution certificate. * * @param string $csr_pem A PEM CSR ("-----BEGIN CERTIFICATE REQUEST-----..."). * * @return array{id:string,content:string}|\WP_Error * id = certificate resource id; content = base64 DER cert. */ public function create_distribution_certificate( $csr_pem ) { // 'DISTRIBUTION' = the modern "Apple Distribution" certificate // (2-3 allowed per account, CN "Apple Distribution: ..."), which // matches CODE_SIGN_IDENTITY="Apple Distribution" and the export // plist. 'IOS_DISTRIBUTION' is the legacy "iPhone Distribution" // type, capped at one per account. $created = $this->request( 'POST', '/v1/certificates', array( 'data' => array( 'type' => 'certificates', 'attributes' => array( 'certificateType' => 'DISTRIBUTION', 'csrContent' => $csr_pem, ), ), ) ); if ( is_wp_error( $created ) ) { return $created; } $id = (string) ( $created['data']['id'] ?? '' ); $content = (string) ( $created['data']['attributes']['certificateContent'] ?? '' ); if ( '' === $id || '' === $content ) { return new \WP_Error( 'esipher_asc_cert', __( 'App Store Connect did not return a usable certificate.', 'esipher' ) ); } return array( 'id' => $id, 'content' => $content ); } /** * Delete every existing IOS_APP_STORE profile with this exact name * (so a re-provision replaces cleanly rather than colliding). * * @param string $name * * @return void */ public function delete_profiles_named( $name ) { $found = $this->request( 'GET', '/v1/profiles?filter%5Bname%5D=' . rawurlencode( $name ) . '&limit=200' ); if ( is_wp_error( $found ) ) { return; } foreach ( (array) ( $found['data'] ?? array() ) as $row ) { if ( ! empty( $row['id'] ) ) { $this->request( 'DELETE', '/v1/profiles/' . rawurlencode( $row['id'] ) ); } } } /** * Create an App Store provisioning profile tying the bundle ID to * the certificate. * * @param string $name Profile name. * @param string $bundle_id_res_id Bundle ID resource id. * @param string $certificate_id Certificate resource id. * * @return string|\WP_Error base64 .mobileprovision contents. */ public function create_app_store_profile( $name, $bundle_id_res_id, $certificate_id ) { $created = $this->request( 'POST', '/v1/profiles', array( 'data' => array( 'type' => 'profiles', 'attributes' => array( 'name' => $name, 'profileType' => 'IOS_APP_STORE', ), 'relationships' => array( 'bundleId' => array( 'data' => array( 'type' => 'bundleIds', 'id' => $bundle_id_res_id ), ), 'certificates' => array( 'data' => array( array( 'type' => 'certificates', 'id' => $certificate_id ), ), ), ), ), ) ); if ( is_wp_error( $created ) ) { return $created; } $content = (string) ( $created['data']['attributes']['profileContent'] ?? '' ); if ( '' === $content ) { return new \WP_Error( 'esipher_asc_profile', __( 'App Store Connect did not return a provisioning profile.', 'esipher' ) ); } return $content; } /** * @param string $method * @param string $path * @param array|null $body * * @return array|\WP_Error */ private function request( $method, $path, $body = null ) { $jwt = $this->jwt(); if ( is_wp_error( $jwt ) ) { return $jwt; } $args = array( 'method' => $method, 'headers' => array( 'Authorization' => 'Bearer ' . $jwt, 'Content-Type' => 'application/json', 'Accept' => 'application/json', 'User-Agent' => 'Esipher-WordPress-Plugin', ), 'timeout' => 30, ); if ( null !== $body ) { $args['body'] = wp_json_encode( $body ); } $response = wp_remote_request( self::BASE . $path, $args ); if ( is_wp_error( $response ) ) { return $response; } $code = (int) wp_remote_retrieve_response_code( $response ); $decoded = json_decode( (string) wp_remote_retrieve_body( $response ), true ); if ( $code < 200 || $code >= 300 ) { $detail = ''; if ( is_array( $decoded ) && ! empty( $decoded['errors'][0] ) ) { $err = $decoded['errors'][0]; $detail = trim( ( $err['title'] ?? '' ) . ' ' . ( $err['detail'] ?? '' ) ); } return new \WP_Error( $code, '' !== $detail ? $detail /* translators: %d: HTTP status code. */ : sprintf( __( 'App Store Connect request failed (HTTP %d).', 'esipher' ), $code ) ); } return is_array( $decoded ) ? $decoded : array(); } /** * @return string|\WP_Error A signed ES256 JWT for the ASC API. */ private function jwt() { if ( '' === trim( $this->p8 ) || '' === $this->key_id || '' === $this->issuer_id ) { return new \WP_Error( 'esipher_asc_creds', __( 'The App Store Connect API details are incomplete.', 'esipher' ) ); } $pkey = openssl_pkey_get_private( $this->p8 ); if ( false === $pkey ) { return new \WP_Error( 'esipher_asc_key', __( 'The App Store Connect .p8 key could not be read — re-paste it on the iOS tab.', 'esipher' ) ); } $header = array( 'alg' => 'ES256', 'kid' => $this->key_id, 'typ' => 'JWT' ); $payload = array( 'iss' => $this->issuer_id, 'iat' => time() - 5, 'exp' => time() + 1200, 'aud' => 'appstoreconnect-v1', ); $signing_input = $this->b64url( wp_json_encode( $header ) ) . '.' . $this->b64url( wp_json_encode( $payload ) ); $der = ''; $ok = openssl_sign( $signing_input, $der, $pkey, OPENSSL_ALGO_SHA256 ); if ( ! $ok ) { return new \WP_Error( 'esipher_asc_sign', __( 'Could not sign the App Store Connect request.', 'esipher' ) ); } $raw = $this->der_to_raw_signature( $der, 32 ); if ( is_wp_error( $raw ) ) { return $raw; } return $signing_input . '.' . $this->b64url( $raw ); } /** * Convert an ECDSA signature from DER (SEQUENCE of two INTEGERs) to * the fixed-length R||S concatenation JOSE/ES256 expects. * * @param string $der * @param int $part_len Bytes per integer (32 for P-256). * * @return string|\WP_Error */ private function der_to_raw_signature( $der, $part_len ) { $offset = 0; if ( ! isset( $der[1] ) || "\x30" !== $der[0] ) { return new \WP_Error( 'esipher_asc_sig', __( 'Unexpected signature format from OpenSSL.', 'esipher' ) ); } $offset = 2; // Skip SEQUENCE tag + length (short form assumed; ES256 sigs are always < 128 bytes). $read_int = function ( &$pos ) use ( $der, $part_len ) { if ( ! isset( $der[ $pos ] ) || "\x02" !== $der[ $pos ] ) { return null; } $len = ord( $der[ $pos + 1 ] ); $val = substr( $der, $pos + 2, $len ); $pos += 2 + $len; $val = ltrim( $val, "\x00" ); return str_pad( $val, $part_len, "\x00", STR_PAD_LEFT ); }; $r = $read_int( $offset ); $s = $read_int( $offset ); if ( null === $r || null === $s ) { return new \WP_Error( 'esipher_asc_sig', __( 'Could not parse the request signature.', 'esipher' ) ); } return $r . $s; } /** * @param string $data * * @return string URL-safe base64, no padding. */ private function b64url( $data ) { return rtrim( strtr( base64_encode( (string) $data ), '+/', '-_' ), '=' ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- JOSE base64url encoding, not obfuscation. } }