1+ import base64
12from dataclasses import dataclass
23import json
34import pathlib
45from functools import cache
56import os
67import shutil
78import time
8- from typing import Any , Mapping , Sequence
9+ from typing import Any , Mapping , Sequence , List , Optional
910from build import generate_sha
1011from const import APPLE_TEAM_ID , CHAT_BINARY_NAME , CHAT_PACKAGE_NAME , LINUX_ARCHIVE_NAME
11- from signing import (
12- load_gpg_signer ,
13- )
14- from util import debug , info , isDarwin , run_cmd , run_cmd_output , warn
12+ from util import debug , info , isDarwin , isLinux , run_cmd , run_cmd_output , warn
1513from rust import cargo_cmd_name , rust_env , rust_targets
1614from importlib import import_module
1715
16+ Args = Sequence [str | os .PathLike ]
17+ Env = Mapping [str , str | os .PathLike ]
18+ Cwd = str | os .PathLike
19+
1820BUILD_DIR_RELATIVE = pathlib .Path (os .environ .get ("BUILD_DIR" ) or "build" )
1921BUILD_DIR = BUILD_DIR_RELATIVE .absolute ()
2022
21- REGION = "us-west-2"
23+ CD_SIGNER_REGION = "us-west-2"
2224SIGNING_API_BASE_URL = "https://api.signer.builder-tools.aws.dev"
2325MACOS_BUNDLE_ID = "com.amazon.codewhisperer"
2426
2729class CdSigningData :
2830 bucket_name : str
2931 '''The bucket hosting signing artifacts accessible by CD Signer.'''
30- aws_account_id : str
31- '''The id of the account hosting the build'''
32- aws_region : str
33- '''The AWS region where the app is built'''
34- notarizing_secret_id : str
35- signing_role_name : str
32+ apple_notarizing_secret_arn : str
33+ '''The ARN of the secret containing the Apple ID and password, used during notarization'''
34+ signing_role_arn : str
35+ '''The ARN of the role used by CD Signer'''
36+
37+
38+ @dataclass
39+ class MacOSBuildOutput :
40+ chat_path : pathlib .Path
41+ '''The path to the chat binary'''
42+ chat_zip_path : pathlib .Path
43+ '''The path to the chat binary zipped'''
3644
3745
3846def run_cargo_tests ():
@@ -127,7 +135,7 @@ def cd_signer_request(method: str, path: str, data: str | None = None):
127135 url = f"{ SIGNING_API_BASE_URL } { path } "
128136 headers = {"Content-Type" : "application/json" }
129137 request = AWSRequest (method = method , url = url , data = data , headers = headers )
130- SigV4Auth (get_creds (), "signer-builder-tools" , REGION ).add_auth (request )
138+ SigV4Auth (get_creds (), "signer-builder-tools" , CD_SIGNER_REGION ).add_auth (request )
131139
132140 for i in range (1 , 8 ):
133141 debug (f"Sending request { method } to { url } with data: { data } " )
@@ -167,7 +175,7 @@ def cd_signer_start_request(request_id: str, source_key: str, destination_key: s
167175 path = f"/signing_requests/{ request_id } /start" ,
168176 data = json .dumps (
169177 {
170- "iamRole" : f"arn:aws:iam:: { signing_data .aws_account_id } :role/ { signing_data . signing_role_name } " ,
178+ "iamRole" : f"{ signing_data .signing_role_arn } " ,
171179 "s3Location" : {
172180 "bucket" : signing_data .bucket_name ,
173181 "sourceKey" : source_key ,
@@ -237,12 +245,18 @@ def manifest(
237245 }
238246
239247
240- def sign_executable (signing_data : CdSigningData , chat_path : pathlib .Path ) -> pathlib .Path :
241- name = chat_path .name
248+ def sign_executable (signing_data : CdSigningData , exe_path : pathlib .Path ) -> pathlib .Path :
249+ '''
250+ Signs an executable with CD Signer.
251+
252+ Returns:
253+ The path to the signed executable
254+ '''
255+ name = exe_path .name
242256 info (f"Signing { name } " )
243257
244258 info ("Packaging..." )
245- package_path = cd_build_signed_package (chat_path )
259+ package_path = cd_build_signed_package (exe_path )
246260
247261 info ("Uploading..." )
248262 run_cmd (["aws" , "s3" , "rm" , "--recursive" , f"s3://{ signing_data .bucket_name } /signed" ])
@@ -283,33 +297,40 @@ def sign_executable(signing_data: CdSigningData, chat_path: pathlib.Path) -> pat
283297
284298 info ("Signed!" )
285299
300+ # CD Signer should return the signed executable in a zip file containing the structure:
301+ # "Payload/EXECUTABLES_TO_SIGN/{executable name}".
286302 info ("Downloading..." )
287- # CD Signer should return the signed executable under "Payload/EXECUTABLES_TO_SIGN/{executable name}"
288- run_cmd (["aws" , "s3" , "cp" , f"s3://{ signing_data .bucket_name } /signed/signed.zip" , "signed.zip" ], cwd = BUILD_DIR )
289303
290304 # Create a new directory for unzipping the signed executable.
305+ zip_dl_path = BUILD_DIR / pathlib .Path ("signed.zip" )
306+ run_cmd (["aws" , "s3" , "cp" , f"s3://{ signing_data .bucket_name } /signed/signed.zip" , zip_dl_path ])
291307 payload_path = BUILD_DIR / "signed"
292308 shutil .rmtree (payload_path , ignore_errors = True )
293- run_cmd (["unzip" , "signed.zip" , "-d" , payload_path ], cwd = BUILD_DIR )
309+ run_cmd (["unzip" , zip_dl_path , "-d" , payload_path ])
310+ zip_dl_path .unlink ()
294311 signed_exe_path = BUILD_DIR / "signed" / "Payload" / "EXECUTABLES_TO_SIGN" / name
295312 # Verify that the exe is signed
296313 run_cmd (["codesign" , "--verify" , "--verbose=4" , signed_exe_path ])
297314 return signed_exe_path
298315
299316
300- def notarize_executable (signing_data : CdSigningData , executable_path : pathlib .Path ):
317+ def notarize_executable (signing_data : CdSigningData , exe_path : pathlib .Path ):
318+ '''
319+ Submits an executable to Apple notary service.
320+ '''
301321 # Load the Apple id and password from secrets manager.
302- secret_id = signing_data .notarizing_secret_id
322+ secret_id = signing_data .apple_notarizing_secret_arn
323+ secret_region = parse_region_from_arn (signing_data .apple_notarizing_secret_arn )
303324 info (f"Loading secretmanager value: { secret_id } " )
304- secret_value = run_cmd_output (["aws" , "--region" , signing_data . aws_region , "secretsmanager" , "get-secret-value" , "--secret-id" , secret_id ])
325+ secret_value = run_cmd_output (["aws" , "--region" , secret_region , "secretsmanager" , "get-secret-value" , "--secret-id" , secret_id ])
305326 secret_string = json .loads (secret_value )["SecretString" ]
306327 secrets = json .loads (secret_string )
307328
308329 # Submit the exe to Apple notary service. It must be zipped first.
309- info (f"Submitting { executable_path } to Apple notary service" )
310- zip_path = BUILD_DIR / f"{ executable_path .name } .zip"
330+ info (f"Submitting { exe_path } to Apple notary service" )
331+ zip_path = BUILD_DIR / f"{ exe_path .name } .zip"
311332 zip_path .unlink (missing_ok = True )
312- run_cmd (["zip" , "-j" , zip_path , executable_path ], cwd = BUILD_DIR )
333+ run_cmd (["zip" , "-j" , zip_path , exe_path ], cwd = BUILD_DIR )
313334 submit_res = run_cmd_output (
314335 [
315336 "xcrun" ,
@@ -331,27 +352,142 @@ def notarize_executable(signing_data: CdSigningData, executable_path: pathlib.Pa
331352 # Confirm notarization succeeded.
332353 assert (json .loads (submit_res )["status" ] == "Accepted" )
333354
355+ # Cleanup
356+ zip_path .unlink ()
334357
335- def sign_and_notarize (signing_data : CdSigningData , chat_path : pathlib .Path ):
358+
359+ def sign_and_notarize (signing_data : CdSigningData , chat_path : pathlib .Path ) -> pathlib .Path :
360+ '''
361+ Signs an executable with CD Signer, and verifies it with Apple notary service.
362+
363+ Returns:
364+ The path to the signed executable.
365+ '''
336366 # First, sign the application
337- executable_path = sign_executable (signing_data , chat_path )
367+ chat_path = sign_executable (signing_data , chat_path )
338368
339369 # Next, notarize the application
340- notarize_executable (signing_data , executable_path )
370+ notarize_executable (signing_data , chat_path )
371+
372+ return chat_path
373+
341374
342375
343376def build_macos (chat_path : pathlib .Path , signing_data : CdSigningData | None ):
344- chat_dst = BUILD_DIR / "qchat"
377+ '''
378+ Creates a qchat.zip under the build directory.
379+ '''
380+ chat_dst = BUILD_DIR / CHAT_BINARY_NAME
345381 chat_dst .unlink (missing_ok = True )
346382 shutil .copy2 (chat_path , chat_dst )
347383
348384 if signing_data :
349- sign_and_notarize (signing_data , chat_dst )
385+ chat_dst = sign_and_notarize (signing_data , chat_dst )
350386
351- return chat_dst
387+ zip_path = BUILD_DIR / f"{ CHAT_BINARY_NAME } .zip"
388+ zip_path .unlink (missing_ok = True )
389+
390+ info (f"Creating zip output to { zip_path } " )
391+ run_cmd (["zip" , "-j" , zip_path , chat_dst ], cwd = BUILD_DIR )
392+ generate_sha (zip_path )
393+
394+
395+ class GpgSigner :
396+ def __init__ (self , gpg_id : str , gpg_secret_key : str , gpg_passphrase : str ):
397+ self .gpg_id = gpg_id
398+ self .gpg_secret_key = gpg_secret_key
399+ self .gpg_passphrase = gpg_passphrase
352400
401+ self .gpg_home = pathlib .Path .home () / ".gnupg-tmp"
402+ self .gpg_home .mkdir (parents = True , exist_ok = True , mode = 0o700 )
353403
354- def build_linux (chat_path : pathlib .Path ):
404+ # write gpg secret key to file
405+ self .gpg_secret_key_path = self .gpg_home / "gpg_secret"
406+ self .gpg_secret_key_path .write_bytes (base64 .b64decode (gpg_secret_key ))
407+
408+ self .gpg_passphrase_path = self .gpg_home / "gpg_pass"
409+ self .gpg_passphrase_path .write_text (gpg_passphrase )
410+
411+ run_cmd (["gpg" , "--version" ])
412+
413+ info ("Importing GPG key" )
414+ run_cmd (["gpg" , "--list-keys" ], env = self .gpg_env ())
415+ run_cmd (
416+ ["gpg" , * self .sign_args (), "--allow-secret-key-import" , "--import" , self .gpg_secret_key_path ],
417+ env = self .gpg_env (),
418+ )
419+ run_cmd (["gpg" , "--list-keys" ], env = self .gpg_env ())
420+
421+ def gpg_env (self ) -> Env :
422+ return {** os .environ , "GNUPGHOME" : self .gpg_home }
423+
424+ def sign_args (self ) -> Args :
425+ return [
426+ "--batch" ,
427+ "--pinentry-mode" ,
428+ "loopback" ,
429+ "--no-tty" ,
430+ "--yes" ,
431+ "--passphrase-file" ,
432+ self .gpg_passphrase_path ,
433+ ]
434+
435+ def sign_file (self , path : pathlib .Path ) -> List [pathlib .Path ]:
436+ info (f"Signing { path .name } " )
437+ run_cmd (
438+ ["gpg" , "--detach-sign" , * self .sign_args (), "--local-user" , self .gpg_id , path ],
439+ env = self .gpg_env (),
440+ )
441+ run_cmd (
442+ ["gpg" , "--detach-sign" , * self .sign_args (), "--armor" , "--local-user" , self .gpg_id , path ],
443+ env = self .gpg_env (),
444+ )
445+ return [path .with_suffix (f"{ path .suffix } .asc" ), path .with_suffix (f"{ path .suffix } .sig" )]
446+
447+ def clean (self ):
448+ info ("Cleaning gpg keys" )
449+ shutil .rmtree (self .gpg_home , ignore_errors = True )
450+
451+
452+ def get_secretmanager_json (secret_id : str , secret_region : str ):
453+ info (f"Loading secretmanager value: { secret_id } " )
454+ secret_value = run_cmd_output (["aws" , "--region" , secret_region , "secretsmanager" , "get-secret-value" , "--secret-id" , secret_id ])
455+ secret_string = json .loads (secret_value )["SecretString" ]
456+ return json .loads (secret_string )
457+
458+
459+ def load_gpg_signer () -> Optional [GpgSigner ]:
460+ if gpg_id := os .getenv ("TEST_PGP_ID" ):
461+ gpg_secret_key = os .getenv ("TEST_PGP_SECRET_KEY" )
462+ gpg_passphrase = os .getenv ("TEST_PGP_PASSPHRASE" )
463+ if gpg_secret_key is not None and gpg_passphrase is not None :
464+ info ("Using test pgp key" , gpg_id )
465+ return GpgSigner (gpg_id = gpg_id , gpg_secret_key = gpg_secret_key , gpg_passphrase = gpg_passphrase )
466+
467+ pgp_secret_arn = os .getenv ("SIGNING_PGP_KEY_SECRET_ARN" )
468+ info (f"SIGNING_PGP_KEY_SECRET_ARN: { pgp_secret_arn } " )
469+ if pgp_secret_arn :
470+ pgp_secret_region = parse_region_from_arn (pgp_secret_arn )
471+ gpg_secret_json = get_secretmanager_json (pgp_secret_arn , pgp_secret_region )
472+ gpg_id = gpg_secret_json ["gpg_id" ]
473+ gpg_secret_key = gpg_secret_json ["gpg_secret_key" ]
474+ gpg_passphrase = gpg_secret_json ["gpg_passphrase" ]
475+ return GpgSigner (gpg_id = gpg_id , gpg_secret_key = gpg_secret_key , gpg_passphrase = gpg_passphrase )
476+ else :
477+ return None
478+
479+
480+ def parse_region_from_arn (arn : str ) -> str :
481+ # ARN format: arn:partition:service:region:account-id:resource-type/resource-id
482+ # Check if we have enough parts and the ARN starts with "arn:"
483+ parts = arn .split (":" )
484+ if len (parts ) >= 4 :
485+ return parts [3 ]
486+
487+ return ""
488+
489+
490+ def build_linux (chat_path : pathlib .Path , signer : GpgSigner | None ):
355491 """
356492 Creates tar.gz, tar.xz, tar.zst, and zip archives under `BUILD_DIR`.
357493
@@ -364,8 +500,6 @@ def build_linux(chat_path: pathlib.Path):
364500 archive_path .mkdir (parents = True , exist_ok = True )
365501 shutil .copy2 (chat_path , archive_path / CHAT_BINARY_NAME )
366502
367- signer = load_gpg_signer ()
368-
369503 info (f"Building { archive_name } .tar.gz" )
370504 tar_gz_path = BUILD_DIR / f"{ archive_name } .tar.gz"
371505 run_cmd (["tar" , "-czf" , tar_gz_path , archive_path ])
@@ -388,25 +522,23 @@ def build_linux(chat_path: pathlib.Path):
388522
389523def build (
390524 release : bool ,
391- output_bucket : str | None = None ,
392- signing_bucket : str | None = None ,
393- aws_account_id : str | None = None ,
394- aws_region : str | None = None ,
395- apple_id_secret : str | None = None ,
396- signing_role_name : str | None = None ,
397525 stage_name : str | None = None ,
398526 run_lints : bool = True ,
399527 run_test : bool = True ,
400528):
401529 BUILD_DIR .mkdir (exist_ok = True )
402530
403- if signing_bucket and aws_account_id and aws_region and apple_id_secret and signing_role_name :
531+ disable_signing = os .environ .get ('DISABLE_SIGNING' )
532+
533+ gpg_signer = load_gpg_signer () if not disable_signing and isLinux () else None
534+ signing_role_arn = os .environ .get ('SIGNING_ROLE_ARN' )
535+ signing_bucket_name = os .environ .get ('SIGNING_BUCKET_NAME' )
536+ signing_apple_notarizing_secret_arn = os .environ .get ('SIGNING_APPLE_NOTARIZING_SECRET_ARN' )
537+ if not disable_signing and isDarwin () and signing_role_arn and signing_bucket_name and signing_apple_notarizing_secret_arn :
404538 signing_data = CdSigningData (
405- bucket_name = signing_bucket ,
406- aws_account_id = aws_account_id ,
407- aws_region = aws_region ,
408- notarizing_secret_id = apple_id_secret ,
409- signing_role_name = signing_role_name ,
539+ bucket_name = signing_bucket_name ,
540+ apple_notarizing_secret_arn = signing_apple_notarizing_secret_arn ,
541+ signing_role_arn = signing_role_arn ,
410542 )
411543 else :
412544 signing_data = None
@@ -423,7 +555,7 @@ def build(
423555
424556 info (f"Release: { release } " )
425557 info (f"Targets: { targets } " )
426- info (f"Signing app: { signing_data is not None } " )
558+ info (f"Signing app: { signing_data is not None or gpg_signer is not None } " )
427559
428560 if run_test :
429561 info ("Running cargo tests" )
@@ -441,13 +573,6 @@ def build(
441573 )
442574
443575 if isDarwin ():
444- chat_path = build_macos (chat_path , signing_data )
445- sha_path = generate_sha (chat_path )
446-
447- if output_bucket :
448- staging_location = f"s3://{ output_bucket } /staging/"
449- info (f"Build complete, sending to { staging_location } " )
450- run_cmd (["aws" , "s3" , "cp" , chat_path , staging_location ])
451- run_cmd (["aws" , "s3" , "cp" , sha_path , staging_location ])
576+ build_macos (chat_path , signing_data )
452577 else :
453- build_linux (chat_path )
578+ build_linux (chat_path , gpg_signer )
0 commit comments