ホームページ開発ツール>Xojo / Real Studio Trial and Error・コマンドラインでNotary Serviceをダイレクトに操作する

 Xojo / Real Studio Trial and Error

コマンドラインでNotary Serviceをダイレクトに操作する

目次
 はじめに

 以下は、Xojo Cocoaビルドについての話題です。

 標準的なNotarizationの手法が使えない時の、代替手法を調べてみました。

 なお検証には、Xojo 2022 Release 4.1を用いています。(Mac mini 2018 + macOS 13.4.1 Ventura)


 経緯

 macOSアプリの公証は、Xojo製のようにXcodeで直接行えない場合は、従来はaltoolで行ってきましたが、notarytoolに変更になりました。
 altoolは2023年11月以降は使えなくなってしまうので、notarytoolに乗り換える必要があります。

 参考サイト(1):Customizing the notarization workflow | Apple Developer Documentation

 当方でも発表直後から試行してきましたが、アップロードの途中、なぜか3MBを超えるあたりで常にエラーとなってしまいます。
(注:3MB以内のファイルであれば、正常に処理されます。)
 メッセージの様子からは、応答がなくなってタイムアウトした、ような印象です。
Error: abortedUpload(resumeRequest: SotoS3.S3.ResumeMultipartUploadRequest(uploadRequest: SotoS3.S3.CreateMultipartUploadRequest(acl: nil, bucket: "notary-submissions-prod", bucketKeyEnabled: nil, cacheControl: nil, contentDisposition: nil, contentEncoding: nil, contentLanguage: nil, contentType: nil, expectedBucketOwner: nil, _expires: SotoCore.OptionalCustomCoding(value: nil), grantFullControl: nil, grantRead: nil, grantReadACP: nil, grantWriteACP: nil, key: "prod/AROARQRX7CZS3PRF6ZA5L:fa35c1d2-a487-4408-bc51-6c8b69d082e3", metadata: nil, objectLockLegalHoldStatus: nil, objectLockMode: nil, _objectLockRetainUntilDate: SotoCore.OptionalCustomCoding(value: nil), requestPayer: nil, serverSideEncryption: nil, sSECustomerAlgorithm: nil, sSECustomerKey: nil, sSECustomerKeyMD5: nil, sSEKMSEncryptionContext: nil, sSEKMSKeyId: nil, storageClass: nil, tagging: nil, websiteRedirectLocation: nil), uploadId: "ngPRrcW5MM5f51_ypYU7EACjMTr1GuzIUL2TMWntqZEUhEO.zyqZPcrqJindTf3fpvnBrRgowTkcVMMNXQQNNS8CQ8zZXlOEQLKAmM3GJ4yBckqbF.Hj5Bq5grPSMNkeLxEoUTaZzsPnj3NERXgiXy8MWyR0FumSL3em_Qf8kaoLc9DzvF9_GzGIXgHalsqB", completedParts: []), error: HTTPClientError.deadlineExceeded)
ちなみに、上記メッセージ(の一部)でググっても、何もヒットしない。
ということは、当方の通信?環境が極めて特殊?で、タイムアウトする人なんて他にはいない、ということか?
だとすれば、ここに書いたことは誰も読まないことになるが、まぁいいか。
 なので、仕方なくaltoolを使い続けていたのですが、そろそろそうも言っていられなくなってきました。
 そこで調べていたら、以下がヒットしました。
 この方法でも同様にエラーで終わる可能性も考えられますが、他に選択肢もなさそうだったので、ひとまず試してみることにしました。

 参考サイト(2):Submitting software for notarization over the web | Apple Developer Documentation

 読むと、大まかな手順としては、こんな感じです。
1. Create a Private Key
2. Generating Token
3. Submission & Upload
4. Check the Status ( and Get Log )
 それぞれ、具体的に見ていきます。

1. Create a Private Key
 App Store Connectにログインして、Private Key(サイト上ではAPIキーと表記)を取得します。参考サイト(3)の手順通りにすれば取得できます。
 この時、APIキーだけでなく、Issuer IDキーIDも必要になるので、控えておきます。

 参考サイト(3):Creating API Keys for App Store Connect API | Apple Developer Documentation

2. Generating Token

 トークンを生成します。参考サイト(4)に手順が書かれています。

 参考サイト(4):Generating Tokens for API Requests | Apple Developer Documentation

 アップル自身はトークン生成機能を提供していないので、外部に頼ることになりますが、手っ取り早いのはjwt.ioのDebuggerを使わせて頂くことでしょう。
 必要になるのは、Header、Payload、Private Key、Public Keyになります。
 このうち、Header、Payloadは参考サイト(4)に書かれている通りに作成します。
 Public Keyは、Private Keyから抽出します。が、ここで問題が。

 jwt.ioのDebuggerに上記をコピー&ペーストしても、Invalid Signatureになってしまいます。
 Debuggerページをよく見ると、Private KeyはPKCSの#8と#1に対応していますが、Public Keyは#1のみとなっています。
 手元にあるPrivate Keyは#8なので、抽出したPublic Keyも#8となっているため、これを#1に変換しようとしたのですが、エラーになってしまいました。変換方法は複数あるのでいろいろ試しましたが、いずれもダメでした。
 手詰まってしまいましたが、ふと、Private Keyを#8から#1に変換後にPublic Keyを取り出せばいいのでは、と思い、やってみたらうまくいきました。
 やり方は、ターミナルで、
openssl pkcs8 -topk8 -inform pem -in AuthKey_XXXXXXXXXX.p8 -outform pem -nocrypt -out privkey.p1
openssl ec -in privkey.p1 -pubout -out pubkey.p1
 再度、jwt.ioのDebuggerに代入したところ、トークンが表示されました。
jwt.ioのDebugger上でエンコードする場合は、Private Key、Public Keyとも#1でないと、Invalid Signatureになってしまう。
一方、(別の処で作られたものを)デコードする場合は、#8でエンコードされたものは#8のPublic Keyで、Signature Verifiedになる。
この辺よくわからない。(実装上の制約?)
3. Submission & Upload
 参考サイト(2)の実行例は、Python3で書かれています。
 当方には、Python3がインストールされていなかったので、まずはここから始めました。(導入方法は様々なサイトで解説されています。)
 本体の他、requests、boto3、も追加でインストールしておきます。

 参考サイト(2)ではスクリプトが3箇所に分かれて記述されていますが、これらは一つに纏めて記述できます。
 ただし、Config絡みのエラーが出たので、import boto3の後にfrom botocore.config import Configを追加しておきました。

 トークンは# Defined elsewhere.と書かれていますが、ここではテストなので、上記トークンをダブルコーテーションで括って直接指定しました。
 アップロードするファイル名(3箇所)も書き換えておきます。
 なお、最初のスクリプトの、bodyのnotificationsは不要だったので削除しておきました。(前行最後のカンマ削除を忘れずに)

 サーバーからのレスポンス(参考サイト(2)では赤字で示されているもの)のうち、IDはステータスの確認等に必要となるので書き出すようにしておきます。
 output = resp.json()の後に以下を追加しました。
idnumber = output["data"]["id"]  # get ID
print(idnumber)  # output ID

 これで、必要な環境は揃いました。あとは上記Pythonスクリプトを実行するだけです。
 肝心の3MB超アップロードですが、、、正常にできました!
 お陰で、11月以降もNotalizeなしで公開せずに済みそうです。

4. Check the Status ( and Get Log )
 参考サイト(2)のcurlコマンドで確認しますが、<token>は作成したトークン、最後の2efe2717-52ef-43a5-96dc-0797e4ca1041は3.のPythonスクリプト実行中に書き出されたIDに置き換えます。

 ここまでで、ルートの確認はできましたが、トークンの作成をウェブ上で手作業で行うステップを含む方法は使い勝手がいいとは言えないので、コード化できないか探ってみました。

・jwt.ioでは各言語用にライブラリが用意されているのですが、ドキュメント類が見つからなかったので諦めました。
・Objecive-Cのサンプルソースコードが公開されていて、そのうちのyourkarma / JWTを使わせて頂いたのですが、エラーが発生して、それ以上進めませんでした。
・Opensslのdgstは、処理はできたものの、jwt.ioのDebuggerに代入するとInvalid Signatureに。(ES256に対応していない?)
・PythonライブラリのPyJWTは、以下のサイトを参考にさせて頂いて試したところ、jwt.ioのDebuggerでSignature Verifiedになりました。(注:cryptographyの追加インストールが必要でした。)

 参考サイト(5):PyJWTを利用したES256形式のJWT生成方法 メモ - Qiita

 ということで、一連の作業をツールとして纏められる目処が立ちました。

 さて、上記手作業での確認では、固有の情報をスクリプト等に埋め込んで使いましたが、ツール化するとなると、汎用化は考えておきたいところです。
 また、トークンには寿命(10〜20分程度にするのが作法?)を設定しているため、一度作ったものを時刻を気にしながら使い回すより、必要になった時点でその都度新規に作成する方が良さそうです。(トークンとIDは紐付けられていないようで、作り直したトークンでも問題はありませんでした。)
 あと、ステータスとログの結果はJSON形式ですが、ベタなテキストで返ってくるので読みづらいため、整形して表示します。

 参考サイト(6):Python Tips: JSON を整形して表示したい - Life with Python

 以上を踏まえ、(残りの)仕様は以下の通りとしました。

 Pythonスクリプトの編集

 jwttoken.py
  1. PyJWTを利用したES256形式のJWT生成方法 メモ - Qiita>実装、のサンプルをベースとする
  2. 先頭部分に以下を追加
    #!/usr/bin/env python3
    
    import sys
    
    args = sys.argv
    
    fname = args[1]
    kid1 = args[2]
    iss1 = args[3]
    iat1 = args[4]
    exp1 = args[5]
    
  3. 以下を改変
    with open('./'+fname) as f_private:
    
    'kid': kid1,
    
    'iss': iss1,
    
    'iat': int(iat1),
    
    'exp': int(exp1),
    
  4. 「##### JWT 検証 #####」以下を削除
 upload.py
  1. アップル公式ページのサンプルがベースのため、改変したものを全文載せています。(出典:Submitting software for notarization over the web | Apple Developer Documentation
    #!/usr/bin/env python3
    
    # Phase 0
    
    import sys
    
    args = sys.argv
    
    token = args[1]
    fname = args[2]
    
    # Phase 1
    
    import hashlib
    
    with open(fname, "rb") as file:
        hash = hashlib.sha256()
        hash.update(file.read())
        sha256 = hash.hexdigest()
    
    body = {
        "submissionName": fname,
        "sha256": sha256
    }
    
    # Phase 2
    
    import requests
    
    #token = generate_token() # Defined elsewhere.
    resp = requests.post("https://appstoreconnect.apple.com/notary/v2/submissions", json=body, headers={"Authorization": "Bearer " + token})
    resp.raise_for_status()
    output = resp.json()
    
    idnumber = output["data"]["id"]  # get ID
    print(idnumber)  # output ID
    
    # Phase 3
    
    import boto3
    from botocore.config import Config  # Added to Original
    
    aws_info = output["data"]["attributes"]
    bucket = aws_info["bucket"]
    key = aws_info["object"]
    sub_id = output["data"]["id"]
    
    s3 = boto3.client(
             "s3",
             aws_access_key_id=aws_info["awsAccessKeyId"],
             aws_secret_access_key=aws_info["awsSecretAccessKey"],
             aws_session_token=aws_info["awsSessionToken"],
             config=Config(s3={"use_accelerate_endpoint": True})
    )
    
    resp = s3.upload_file(fname, bucket, key)
    
 parse.py
  1. Python Tips: JSON を整形して表示したい - Life with Python>シンタックスハイライトなし>JSON が文字列に格納されている場合、のサンプルをベースとする
  2. 先頭部分に以下を追加
    #!/usr/bin/env python3
    
    import sys
    
    args = sys.argv
    
    result = args[1]
    
  3. JSON_SAMPLEを以下に改変
    JSON_SAMPLE = result
    

 Xojoでの実装
注:以下の実装では、一部Xojo 2022r4.1ではDeprecatedな機能が使われています。必要なら推奨される機能に置き換えて下さい。
【ソースコードのコピー&ペーストについて】
・ソースコード(グレー背景部分の全文)をコピーし、指定のオブジェクトにペーストすると、(新規作成して名前等を個別にコピー&ペーストしなくても)復元されます。
・ペーストはオブジェクトに行って下さい。オブジェクト内のEvent Handlers/Methods/Properties等にペーストしても、うまくいかない場合があります。
・それでもペーストできない場合は、各項目のカッコ内を適用して下さい。
  1. Xojoで新規プロジェクトを作成
  2. Window1に、DesktopButton3個(Name:Button1, Caption:Upload、Name:Button2, Caption:Status、Name:Button3, Caption:Log)、DesktopTextArea4個(Name:TextArea1、Name:TextArea2、Name:TextArea3、Name:TextArea4)、DesktopTextField(Name:TextField1)を置く
  3. 以下をButton1にペースト(できなければ、Sub - Endの間をPressedイベントに記述)
    Sub Pressed() Handles Pressed
      Upload()
    End Sub
    
  4. 以下をButton2にペースト(できなければ、Sub - Endの間をPressedイベントに記述)
    Sub Pressed() Handles Pressed
      Status()
    End Sub
    
  5. 以下をButton3にペースト(できなければ、Sub - Endの間をPressedイベントに記述)
    Sub Pressed() Handles Pressed
      Log()
    End Sub
    
  6. 以下をWindow1にペースト(できなければ、Sub - Endの間をOpeningイベントに記述)
    Sub Opening() Handles Opening
      // 以下は、各自の環境に合わせる
      pPrivName="AuthKey_8xxxxxxxx2.p8"
      pIssuerID="6xxxxxxe-686e-47e3-exxxxxxxxxxxxxxx1"
      pKeyID="8xxxxxxxx2"
      pFileName="untitled1.0.0.zip"
    End Sub
    
  7. 以下をWindow1にペースト
    Private Function GetEpochTime(sec As Integer) As String
      Var d1 As DateTime = DateTime.Now
      Var s1 As Double = d1.SecondsFrom1970
      s1=s1+sec
      Var d2 As New DateTime(s1,TimeZone.Current)
      Var s2 As Double = d2.SecondsFrom1970
      Var s2i As Integer = s2
      Var value As Integer = Integer.FromHex(hex(s2i))
      
      return str(value)
    End Function
    
  8. 以下をWindow1にペースト
    Private Sub Log()
      
      // Log確認用文字列生成
      Dim str As String
      str="curl -H ""Authorization: Bearer "+MakeToken()+""" ""https://appstoreconnect.apple.com/notary/v2/submissions/"+TextField1.Text+"/logs"""
      
      // Shell生成
      Var s As Shell
      s = new Shell
      
      // Shell実行
      s.Execute(str)
      if s.ExitCode = 0 then
        TextArea4.Text=ParseJSON(s.Result)
      else
        MessageBox("Error code: " + s.ErrorCode.ToString)
        TextArea4.Text=s.Result
      end if
    End Sub
    
  9. 以下をWindow1にペースト
    Private Function MakeToken() As String
      Dim path As String = GetFolderItem("").ShellPath  // Current Directory Path
      
      // Command生成
      Dim str, str0, str1, str2, str3, str4, str5, str6, str7 As String
      str0=" "
      str1="cd "+path+";/Users/hasu/.pyenv/shims/python "
      str2=SpecialFolder.Resource("jwttoken.py").ShellPath
      str3=pPrivName
      str4=pKeyID
      str5=pIssuerID
      str6=GetEpochTime(0)  // 現在時刻のUNIX Epoch Time
      str7=GetEpochTime(20*60)  // 現在時刻から20分後のUNIX Epoch Time
      str=str1+str2+str0+str3+str0+str4+str0+str5+str0+str6+str0+str7
      
      // Shell生成
      Var s As Shell
      s = new Shell
      
      // Shell実行
      s.Execute(str)
      if s.ExitCode = 0 then
        Dim ss As String = NthField(s.Result,EndOfLine.UNIX,1)  // 最後に改行が付いて返ってくるので、除去する
        TextArea1.Text=ss
        return ss
      else
        MessageBox("Error code: " + s.ErrorCode.ToString)
        return ""
      end if
    End Function
    
  10. 以下をWindow1にペースト
    Private Function ParseJSON(res As String) As String
      Dim path As String = GetFolderItem("").ShellPath  // Current Directory Path
      
      // data以降を抽出
      Dim res2 As String = NthField(res,"{""data"":",2)
      res2="'{""data"":"+res2+"'"
      
      // Command生成
      Dim str, str0, str1, str2, str3 As String
      str0=" "
      str1="cd "+path+";/Users/hasu/.pyenv/shims/python "
      str2=SpecialFolder.Resource("parse.py").ShellPath
      str3=res2  // 本文
      str=str1+str2+str0+str3
      
      // Shell生成
      Var s As Shell
      s = new Shell
      
      // Shell実行
      s.Execute(str)
      if s.ExitCode = 0 then
      else
        MessageBox("Error code: " + s.ErrorCode.ToString)
      end if
      
      // 整形したテキストを返す
      return s.Result
    End Function
    
  11. 以下をWindow1にペースト
    Private Sub Status()
      
      // Status確認用文字列生成
      Dim str As String
      str="curl -H ""Authorization: Bearer "+MakeToken()+""" ""https://appstoreconnect.apple.com/notary/v2/submissions/"+TextField1.Text+""""
      
      // Shell生成
      Var s As Shell
      s = new Shell
      
      // Shell実行
      s.Execute(str)
      if s.ExitCode = 0 then
        TextArea3.Text=ParseJSON(s.Result)
      else
        MessageBox("Error code: " + s.ErrorCode.ToString)
        TextArea3.Text=s.Result
      end if
    End Sub
    
  12. 以下をWindow1にペースト
    Private Sub Upload()
      Dim path As String = GetFolderItem("").ShellPath  // Current Directory Path
      
      // Command生成
      Dim str, str0, str1, str2, str3, str4 As String
      str0=" "
      str1="cd "+path+";/Users/hasu/.pyenv/shims/python "
      str2=SpecialFolder.Resource("upload.py").ShellPath
      str3=MakeToken()  // トークンはその都度生成
      str4=pFileName
      str=str1+str2+str0+str3+str0+str4
      
      // Shell生成
      Var s As Shell
      s = new Shell
      
      // Shell実行
      s.Execute(str)
      if s.ExitCode = 0 then
        Dim ss As String = NthField(s.Result,EndOfLine.UNIX,1)  // 最後に改行が付いて返ってくるので、除去する
        TextField1.Text=ss
      else
        MessageBox("Error code: " + s.ErrorCode.ToString)
        TextArea2.Text=s.Result
      end if
    End Sub
    

  13. プロジェクト左ペインのBuild Settings>macOS上で右クリックし、Add to "Build Settings">Build Step>Copy Filesを選択
  14. InspectorのDestinationにResources Folderを指定
  15. 中央ペインに、jwttoken.pyparse.pyupload.pyをドラッグ&ドロップ
 実行してみたところ、ファイルがアップロードされ、公証を受けられたことを確認しました。
S Shot1

 おわりに

 なぜ、notarytoolがダメで、ダイレクトだとうまくいくのかは、結局よく分かりませんでした。
 いずれにしても、18MB程度のアップロードに成功しているので、代替手段としては問題なく使えるとみて良さそうです。

 ツール化に関しては、使い勝手向上のため、カレントディレクトリを都度変更するか、ファイル類は全てフルパス指定するか、といった改良の余地があります。
 アップロードは同期モードのため、実行中はビジー状態になり、IDも終わってから表示されますが、その間やることもないのでそのままにしてあります。経過時間を表示したり、処理を中止する機能を追加したい等の場合は、非同期モードやインタラクティブモードに設定することになります。

 また、トークンは使い捨て感覚ですが、Issuer IDキーIDAPIキー(Private Key)は使い回しができるようです。
 なお、Stapleのステップはaltoolの時と変わらないため、ここでは省略しています。


 お世話になったサイト

 貴重な情報をご提供頂いている皆様に、お礼申し上げます。(以下、順不同)

 参考サイト(1):Customizing the notarization workflow | Apple Developer Documentation
 参考サイト(2):Submitting software for notarization over the web | Apple Developer Documentation
 参考サイト(3):Creating API Keys for App Store Connect API | Apple Developer Documentation
 参考サイト(4):Generating Tokens for API Requests | Apple Developer Documentation
 参考サイト(5):PyJWTを利用したES256形式のJWT生成方法 メモ - Qiita
 参考サイト(6):Python Tips: JSON を整形して表示したい - Life with Python


 更新履歴

 2023.08.28 新規作成


[Home]  [MacSoft]  [Donation]  [History]  [Privacy Policy]  [Affiliate Policy]