@@ -12,6 +12,8 @@ import {
1212 openSync ,
1313 readSync ,
1414 closeSync ,
15+ readFileSync ,
16+ writeFileSync ,
1517} from "node:fs" ;
1618import os from "node:os" ;
1719import path from "node:path" ;
@@ -382,18 +384,116 @@ function signAppBundle(appBundle: string, identity: string): void {
382384
383385 log ( `Signing ${ path . basename ( appBundle ) } with identity: ${ identity } ` ) ;
384386
385- const items = collectSignables ( appBundle ) ;
386- // Sort by depth descending so children sign before parents.
387- items . sort ( ( a , b ) => depth ( b ) - depth ( a ) ) ;
387+ // codesign does not expand $(AppIdentifierPrefix) / $(CFBundleIdentifier)
388+ // — those are Xcode build-phase substitutions. Render the templates here
389+ // so the signed entitlements contain fully-qualified literals; an
390+ // unexpanded placeholder would make taskgated reject the signature with
391+ // "Code Signature Invalid" at launch.
392+ const teamId = extractTeamId ( identity ) ;
393+ const bundleId = readBundleIdentifier ( appBundle ) ;
394+ const renderDir = mkdtempSync ( path . join ( os . tmpdir ( ) , "dao-entitlements-" ) ) ;
395+ const mainRendered = renderEntitlements (
396+ ENTITLEMENTS ,
397+ renderDir ,
398+ "dao.entitlements" ,
399+ teamId ,
400+ bundleId
401+ ) ;
402+ const helperRendered = renderEntitlements (
403+ HELPER_ENTITLEMENTS ,
404+ renderDir ,
405+ "dao_helper.entitlements" ,
406+ teamId ,
407+ bundleId
408+ ) ;
388409
389- for ( const item of items ) {
390- const ent = isAppBundle ( item ) ? HELPER_ENTITLEMENTS : null ;
391- signOne ( item , identity , ent ) ;
410+ try {
411+ const items = collectSignables ( appBundle ) ;
412+ // Sort by depth descending so children sign before parents.
413+ items . sort ( ( a , b ) => depth ( b ) - depth ( a ) ) ;
414+
415+ for ( const item of items ) {
416+ const ent = isAppBundle ( item ) ? helperRendered : null ;
417+ signOne ( item , identity , ent ) ;
418+ }
419+
420+ // Outer app last.
421+ signOne ( appBundle , identity , mainRendered ) ;
422+ success ( "App bundle signed" ) ;
423+ } finally {
424+ rmSync ( renderDir , { recursive : true , force : true } ) ;
392425 }
426+ }
393427
394- // Outer app last.
395- signOne ( appBundle , identity , ENTITLEMENTS ) ;
396- success ( "App bundle signed" ) ;
428+ // Pull the 10-char Team ID out of "Developer ID Application: Foo Bar (TEAMID1234)".
429+ function extractTeamId ( identity : string ) : string {
430+ const m = identity . match ( / \( ( [ A - Z 0 - 9 ] { 10 } ) \) \s * $ / ) ;
431+ if ( ! m ) {
432+ error (
433+ `Could not extract Team ID from DAO_SIGN_IDENTITY: "${ identity } ".\n` +
434+ ' Expected format: "Developer ID Application: <Name> (TEAMID1234)"'
435+ ) ;
436+ process . exit ( 1 ) ;
437+ }
438+ return m [ 1 ] ;
439+ }
440+
441+ function readBundleIdentifier ( appBundle : string ) : string {
442+ const infoPlist = path . join ( appBundle , "Contents" , "Info.plist" ) ;
443+ if ( ! existsSync ( infoPlist ) ) {
444+ error ( `Info.plist not found inside app bundle: ${ infoPlist } ` ) ;
445+ process . exit ( 1 ) ;
446+ }
447+ // `defaults read` strips the .plist suffix and prints the raw value.
448+ const out = run (
449+ `defaults read "${ infoPlist . replace ( / \. p l i s t $ / , "" ) } " CFBundleIdentifier` ,
450+ { silent : true }
451+ ) . trim ( ) ;
452+ if ( ! out ) {
453+ error ( `CFBundleIdentifier is empty in ${ infoPlist } ` ) ;
454+ process . exit ( 1 ) ;
455+ }
456+ return out ;
457+ }
458+
459+ // Render an entitlements template by substituting Xcode-style placeholders,
460+ // then assert no `$(...)` remain. Any survivor would be silently signed into
461+ // the binary and rejected at launch by taskgated.
462+ //
463+ // Also strips XML comments before writing: codesign's AMFI parser is stricter
464+ // than plutil and rejects some perfectly valid UTF-8 inside <!-- ... -->
465+ // (em dashes, backticks, etc.) with "AMFIUnserializeXML: syntax error".
466+ // Comments are only useful for humans reading the source template anyway —
467+ // no need to ship them into the signature.
468+ function renderEntitlements (
469+ templatePath : string ,
470+ outDir : string ,
471+ outName : string ,
472+ teamId : string ,
473+ bundleId : string
474+ ) : string {
475+ const src = readFileSync ( templatePath , "utf8" ) ;
476+ const stripped = src . replace ( / < ! - - [ \s \S ] * ?- - > / g, "" ) ;
477+ const rendered = stripped
478+ // $(AppIdentifierPrefix) expands to "<TeamID>." (trailing dot — that's
479+ // how Xcode's expansion works, since downstream values concatenate
480+ // "$(AppIdentifierPrefix)$(CFBundleIdentifier)" without a separator).
481+ . replace ( / \$ \( A p p I d e n t i f i e r P r e f i x \) / g, `${ teamId } .` )
482+ . replace ( / \$ \( C F B u n d l e I d e n t i f i e r \) / g, bundleId ) ;
483+
484+ const leftover = rendered . match ( / \$ \( [ ^ ) ] + \) / ) ;
485+ if ( leftover ) {
486+ error (
487+ `Unexpanded placeholder ${ leftover [ 0 ] } in ${ path . basename ( templatePath ) } .\n` +
488+ " Add it to renderEntitlements() in scripts/commands/package.ts,\n" +
489+ " or replace it with a literal value in the entitlements file."
490+ ) ;
491+ process . exit ( 1 ) ;
492+ }
493+
494+ const outPath = path . join ( outDir , outName ) ;
495+ writeFileSync ( outPath , rendered ) ;
496+ return outPath ;
397497}
398498
399499function collectSignables ( root : string ) : string [ ] {
@@ -510,6 +610,23 @@ function verifyCodesign(appBundle: string): void {
510610 run ( `codesign --verify --strict --deep --verbose=2 "${ appBundle } "` , {
511611 silent : false ,
512612 } ) ;
613+ // Last-line-of-defence: inspect the entitlements that actually got signed
614+ // into the bundle. Any `$(...)` survivor here means a placeholder slipped
615+ // past renderEntitlements, and taskgated will SIGKILL the app at launch
616+ // with "Code Signature Invalid".
617+ const embedded = run (
618+ `codesign -d --entitlements - --xml "${ appBundle } "` ,
619+ { silent : true }
620+ ) ;
621+ const leftover = embedded . match ( / \$ \( [ ^ ) ] + \) / ) ;
622+ if ( leftover ) {
623+ error (
624+ `Signed entitlements still contain unexpanded ${ leftover [ 0 ] } .\n` +
625+ " This will cause taskgated to reject the signature at launch.\n" +
626+ " Fix: handle the placeholder in renderEntitlements()."
627+ ) ;
628+ process . exit ( 1 ) ;
629+ }
513630 success ( "Signature verified" ) ;
514631}
515632
0 commit comments