1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

AWS LambdaでApp Store Connect API証明書を自動生成し、S3に保存する

1
Last updated at Posted at 2026-05-08

AWS LambdaでApp Store Connect API証明書(.p12)を自動生成し、S3に保存する

iOSアプリの開発運用において、配布用証明書(iOS Distribution)の作成や更新は避けては通れない作業です。しかし、手動での作業はミスが起きやすく、有効期限の管理も手間がかかります。

本記事では、AWS Lambdaを利用してApp Store Connect APIから証明書を自動発行し、秘密鍵を含めた .p12 形式でS3に保存する Pythonスクリプトの解説をします。

概要とコード

このスクリプトは、以下のプロセスを自動化します。

  • 設定取得: AWS SSM Parameter StoreからAPIキーやパスワードを取得。
  • 認証: App Store Connect APIと通信するためのJWTを生成。
  • 証明書要求生成: ローカル(Lambda内)で2048bit RSA秘密鍵とCSRを生成。
  • Apple API連携: CSRをAppleに送信し、証明書を発行。
  • パッケージング: 発行された証明書と秘密鍵を PKCS#12 (.p12) 形式に変換。
  • 保存: 生成されたファイルをS3バケットにアップロード。
import base64
import json
import logging
import time
import urllib.error
import urllib.parse
import urllib.request

import botocore.session
import botocore.exceptions
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.serialization import pkcs12
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import hashes
from cryptography.x509.oid import NameOID
from cryptography import x509
from cryptography.hazmat.backends import default_backend
import jwt

# === ロガーの設定 ===
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)

# SSM Parameter Store のキー名
SSM_PARAM_NAMES = {
    "KEY_ID": "/<YOUR_LAMBDA_NAME>/key_id",
    "ISSUER_ID": "/<YOUR_LAMBDA_NAME>/issuer_id",
    "API_PRIVATE_KEY": "/<YOUR_LAMBDA_NAME>/api_private_key",  # SecureString推奨
    "S3_BUCKET_NAME": "/<YOUR_LAMBDA_NAME>/s3_bucket_name",
    "P12_PASSWORD": "/<YOUR_LAMBDA_NAME>/p12_password",        # SecureString推奨
}


def load_config_from_ssm():
    """SSM Parameter Store から設定値を一括取得する"""
    logger.info("Fetching configuration from SSM Parameter Store...")
    session = botocore.session.get_session()
    client = session.create_client("ssm")

    # 取得対象のパラメータ名リストを作成
    names = list(SSM_PARAM_NAMES.values())

    try:
        response = client.get_parameters(
            Names=names,
            WithDecryption=True, # SecureStringを自動で復号
        )

        # レスポンスを {パラメータ名: 値} の辞書に変換
        params_dict = {p["Name"]: p["Value"] for p in response.get("Parameters", [])}

        # 不足しているパラメータがないかチェック
        missing = [name for name in names if name not in params_dict]
        if missing:
            missing_message = f"Missing SSM parameters: {missing}"
            raise ValueError(missing_message)

        # アプリケーションで扱いやすい形にマッピングして返す
        return {
            "KEY_ID": params_dict[SSM_PARAM_NAMES["KEY_ID"]],
            "ISSUER_ID": params_dict[SSM_PARAM_NAMES["ISSUER_ID"]],
            "API_PRIVATE_KEY": params_dict[SSM_PARAM_NAMES["API_PRIVATE_KEY"]].replace(r"\n", "\n"),
            "S3_BUCKET_NAME": params_dict[SSM_PARAM_NAMES["S3_BUCKET_NAME"]],
            "P12_PASSWORD": params_dict[SSM_PARAM_NAMES["P12_PASSWORD"]].encode("utf-8"),
        }

    except botocore.exceptions.ClientError as e:
        error_code = e.response.get("Error", {}).get("Code", "Unknown")
        error_message = e.response.get("Error", {}).get("Message", "Unknown")
        logger.error(f"AWS ClientError fetching parameters: {error_code} - {error_message}")
        raise
    except Exception:
        logger.exception("Failed to fetch parameters from SSM")
        raise


def create_signed_jwt(config):
    logger.info("Creating signed JWT for App Store Connect API...")
    header = {
        "alg": "ES256",
        "kid": config["KEY_ID"],
        "typ": "JWT",
    }
    payload = {
        "iss": config["ISSUER_ID"],
        "exp": int(time.time()) + (20 * 60),
        "aud": "appstoreconnect-v1",
    }
    return jwt.encode(payload, config["API_PRIVATE_KEY"], algorithm="ES256", headers=header)


def generate_private_key_and_csr():
    logger.info("Generating 2048-bit RSA Private Key...")
    private_key = rsa.generate_private_key(
        public_exponent=65537,
        key_size=2048,
        backend=default_backend(),
    )

    logger.info("Generating Certificate Signing Request (CSR)...")
    csr = x509.CertificateSigningRequestBuilder().subject_name(x509.Name([
        x509.NameAttribute(NameOID.COMMON_NAME, u"iOS Distribution"),
        x509.NameAttribute(NameOID.COUNTRY_NAME, u"JP"),
    ])).sign(private_key, hashes.SHA256(), default_backend())

    csr_pem = csr.public_bytes(serialization.Encoding.PEM).decode("utf-8")
    return private_key, csr_pem


def request_apple_certificate_urllib(api_token, csr_content):
    logger.info("Requesting certificate generation to Apple API...")
    url = "https://api.appstoreconnect.apple.com/v1/certificates"

    payload = {
        "data": {
            "type": "certificates",
            "attributes": {
                "certificateType": "IOS_DISTRIBUTION",
                "csrContent": csr_content,
            },
        },
    }

    data = json.dumps(payload).encode("utf-8")
    headers = {
        "Authorization": f"Bearer {api_token}",
        "Content-Type": "application/json",
        "Accept": "application/json",
    }

    req = urllib.request.Request(url, data=data, headers=headers, method="POST")  # noqa:S310

    try:
        with urllib.request.urlopen(req, timeout=30) as response:  # noqa:S310
            status = response.getcode()
            body = response.read().decode("utf-8")
            if status == 201:
                logger.info("Certificate successfully generated by Apple.")
                return json.loads(body)
            else:
                raise Exception(f"Unexpected status: {status}, Body: {body}")

    except urllib.error.HTTPError as e:
        error_body = e.read().decode("utf-8")
        logger.error(f"HTTPError from Apple API: {e.code} - {e.reason}")
        logger.error(f"Error Body: {error_body}")
        raise Exception(f"Apple API Error: {error_body}")
    except urllib.error.URLError as e:
        logger.error(f"URLError: {e.reason}")
        raise e


def create_p12_file(private_key, apple_cert_content_base64, password):
    logger.info("Combining private key and Apple certificate into PKCS#12 (.p12) format...")
    cert_bytes = base64.b64decode(apple_cert_content_base64)
    certificate = x509.load_der_x509_certificate(cert_bytes, default_backend())

    p12 = pkcs12.serialize_key_and_certificates(
        name=b"iOS Distribution",
        key=private_key,
        cert=certificate,
        cas=None,
        encryption_algorithm=serialization.BestAvailableEncryption(password)
    )

    return p12


def upload_to_s3_botocore(file_bytes, file_name, config):
    bucket_name = config["S3_BUCKET_NAME"]
    logger.info("Uploading '%s' to S3 bucket '%s'...", file_name, bucket_name)
    session = botocore.session.get_session()
    client = session.create_client("s3")

    try:
        client.put_object(
            Bucket=bucket_name,
            Key=f"certificates/{file_name}",
            Body=file_bytes,
            ContentType="application/x-pkcs12",
            ServerSideEncryption="AES256",
        )
        logger.info("S3 Upload Success.")
    except Exception:
        logger.exception("S3 Upload Failed")
        raise


def lambda_handler(event, context):
    try:
        logger.info("=== Certificate Generation Process Started ===")

        # 1. SSM Parameter Store から設定をロード
        config = load_config_from_ssm()

        # 2. JWT認証トークン生成
        token = create_signed_jwt(config)

        # 3. 秘密鍵とCSR生成
        private_key, csr_pem = generate_private_key_and_csr()

        # 4. Appleへ申請
        response = request_apple_certificate_urllib(token, csr_pem)

        cert_data = response["data"]["attributes"]["certificateContent"]
        cert_id = response["data"]["id"]

        # 5. p12ファイル作成
        p12_data = create_p12_file(private_key, cert_data, config["P12_PASSWORD"])

        # 6. S3へ保存
        file_name = f"dist_cert_{cert_id}.p12"
        upload_to_s3_botocore(p12_data, file_name, config)

        logger.info("=== Process Completed Successfully ===")
        return {
            "statusCode": 200,
            "body": json.dumps({
                "message": "Certificate created and stored successfully.",
                "certificate_id": cert_id,
                "s3_path": f"s3://{config['S3_BUCKET_NAME']}/certificates/{file_name}",
            }),
        }

    except Exception as e:
        logger.exception("CRITICAL ERROR during certificate generation.")
        return {
            "statusCode": 500,
            "body": json.dumps({"error": str(e)}),
        }

Lambdaコードの詳細解説

ソースコードの主要な関数とその役割を解説します。

設定の安全な取得 (load_config_from_ssm)

APIキーやパスワードをコードに直書きするのは厳禁です。この関数では、AWSのSSM Parameter Storeから情報を一括取得します。WithDecryption=True を指定することで、SecureString として保存された機密情報を自動で復号して取得します。

Apple API認証 (create_signed_jwt)

App Store Connect APIとの通信にはJWT(JSON Web Token)が必要です。PyJWT ライブラリを使用し、Appleから提供される .p8 秘密鍵を用いて、有効期限20分のトークンに署名します。

証明書署名要求の作成 (generate_private_key_and_csr)

cryptography ライブラリを使用して、インメモリでRSA秘密鍵とCSR(Certificate Signing Request)を生成します。ファイルとして書き出すことなく処理を行うため、セキュリティ的に安全です。

Appleへの申請 (request_apple_certificate_urllib)

外部ライブラリへの依存を減らすため、標準の urllib を使用してAppleのAPIエンドポイント(/v1/certificates)へPOSTリクエストを送信します。

.p12ファイルの生成 (create_p12_file)

Appleから返ってきたDER形式の証明書データと、最初に生成した秘密鍵を結合し、指定のパスワードで暗号化したPKCS#12ファイルを作成します。

IAMロール(実行権限)の設定

Lambda関数が動作するためには、適切なIAMポリシーが必要です。ここでは、セキュリティのベストプラクティスである「最小権限の原則」に基づいた例を紹介します。

注意: <YOUR_ACCOUNT_ID><YOUR_BUCKET_NAME><YOUR_LAMBDA_NAME>は、ご自身の環境に合わせて書き換えてください。

SSM Parameter Store へのアクセス

Lambdaが特定のパス(例:/appstore_generate_p12/)以下のパラメータのみを読み取れるように制限します。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "AllowGetSSMParameters",
            "Effect": "Allow",
            "Action": [
                "ssm:GetParameters",
                "ssm:GetParameter"
            ],
            "Resource": [
                "arn:aws:ssm:ap-northeast-1:111122223333:parameter/<YOUR_LAMBDA_NAME>/*"
            ]
        }
    ]
}

S3への保存権限

特定のバケット内の特定のパス(例:certificates/)に対してのみ、ファイルの書き込みを許可します。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "AllowS3Upload",
            "Effect": "Allow",
            "Action": [
                "s3:PutObject"
            ],
            "Resource": [
                "arn:aws:s3:::<YOUR_BUCKET_NAME>/certificates/*"
            ]
        }
    ]
}

デプロイ時の注意点

このLambdaを動かすには、標準環境に含まれていない以下のライブラリをパッケージングする必要があります。

  • cryptography
  • PyJWT

これらは Lambda Layer として切り出すか、DockerコンテナベースのLambdaとしてデプロイするのがおすすめです。特に cryptography はバイナリを含むため、デプロイ環境のOS(Amazon Linux 2023など)に合わせてビルドすることに注意してください。

まとめ

この仕組みを導入することで、iOSの証明書管理という「属人的で忘れやすい作業」をコード化(Infrastructure as Code)できます。EventBridgeで定期実行させれば、証明書の期限切れに怯える日々から解放されるはずです。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?