diff --git a/CHANGELOG.md b/CHANGELOG.md index 85bab999a..68eae90ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,22 @@ All notable changes to the Native SDK (formerly zero-native) will be documented in this file. -## 0.10.0 +## 0.10.1 +### Bug Fixes + +- **Complete macOS distribution signing**: Developer ID packages now require an explicit secure timestamp, can notarize through a `notarytool` Keychain profile, and staple and validate both the app and final signed DMG before updater archives are created. + +### Contributors + +- @ctate + + + +## 0.10.0 + ### New Features - **Native macOS app updates**: Apps can publish Ed25519-signed update feeds that verify downloads, atomically replace and relaunch the installed bundle, roll back safely on failure, and integrate with new manifest settings plus CLI key-generation, feed-signing, and updater-package commands (#398). @@ -23,8 +35,6 @@ All notable changes to the Native SDK (formerly zero-native) will be documented - @ctate - @sepehr-safari - - ## 0.9.5 ### New Features diff --git a/build.zig b/build.zig index 7f4272216..0a8c075d6 100644 --- a/build.zig +++ b/build.zig @@ -119,6 +119,9 @@ pub fn build(b: *std.Build) void { _ = b.option(bool, "js-bridge", "Enable optional JavaScript bridge stubs") orelse false; const package_target = b.option(PackageTarget, "package-target", "Package target: macos, windows, linux, ios, android") orelse .macos; const signing_mode = b.option(SigningMode, "signing", "Signing mode: none, adhoc, identity") orelse .none; + const signing_identity = b.option([]const u8, "identity", "Code signing identity for distribution packages"); + const signing_entitlements = b.option([]const u8, "entitlements", "Entitlements plist for identity signing"); + const notary_profile = b.option([]const u8, "notary-profile", "notarytool Keychain profile for notarization"); const package_version = packageVersion(b); const optimize_name = @tagName(optimize); // Resolve against THIS build's root: as a dependency of a user app the @@ -2962,6 +2965,8 @@ pub fn build(b: *std.Build) void { }); package_run.addFileArg(embed_lib.getEmittedBin()); package_run.addArgs(&.{ "--manifest", "app.zon", "--assets", "assets", "--optimize", optimize_name, "--signing", @tagName(signing_mode), "--web-engine", @tagName(web_engine), "--cef-dir", cef_dir }); + if (signing_identity) |identity| package_run.addArgs(&.{ "--identity", identity }); + if (signing_entitlements) |entitlements| package_run.addArgs(&.{ "--entitlements", entitlements }); if (cef_auto_install) package_run.addArg("--cef-auto-install"); package_run.step.dependOn(&embed_lib.step); package_run.step.dependOn(&bundle_run.step); @@ -2999,6 +3004,13 @@ pub fn build(b: *std.Build) void { const package_cef_smoke_step = b.step("test-package-cef-layout", "Verify macOS Chromium package layout"); package_cef_smoke_step.dependOn(&package_cef_check.step); + // The repository's macOS distribution helpers need a real launchable + // executable inside the .app. Keep the notarization helper on the same + // emitted host CLI binary that the signed-package smoke executes below; + // the SDK embed artifact is a static `.a` library and is not a valid + // CFBundleExecutable even though codesign can seal a bundle around it. + const macos_distribution_executable = host_cli_exe.getEmittedBin(); + // Signed-package seal pin: package an ad-hoc signed bundle and prove // the signature survives packaging intact with codesign's own strict // verifier. This is the regression gate for the ordering bug where a @@ -3015,7 +3027,7 @@ pub fn build(b: *std.Build) void { const package_signing_mode: []const u8 = if (b.graph.host.result.os.tag == .macos) "adhoc" else "none"; const package_signing_run = b.addRunArtifact(host_cli_exe); package_signing_run.addArgs(&.{ "package", "--target", "macos", "--output", "zig-out/package/native-sdk-signing-verify.app", "--binary" }); - package_signing_run.addFileArg(host_cli_exe.getEmittedBin()); + package_signing_run.addFileArg(macos_distribution_executable); package_signing_run.addArgs(&.{ "--manifest", "app.zon", "--assets", "assets", "--optimize", optimize_name, "--signing", package_signing_mode }); package_signing_run.has_side_effects = true; const package_signing_check = b.addSystemCommand(&.{ @@ -3027,6 +3039,7 @@ pub fn build(b: *std.Build) void { \\ exit 0 \\fi \\codesign --verify --strict --deep "$app" + \\"$app/Contents/MacOS/native-sdk" version >/dev/null \\grep -q "ad-hoc signed" "$app/Contents/Resources/signing-plan.txt" \\echo "signed package verify ok" , @@ -3094,10 +3107,13 @@ pub fn build(b: *std.Build) void { b.fmt("zig-out/package/native-sdk-{s}-macos-{s}.app", .{ package_version, optimize_name }), "--binary", }); - notarize_run.addFileArg(embed_lib.getEmittedBin()); - notarize_run.addArgs(&.{ "--manifest", "app.zon", "--assets", "assets", "--optimize", optimize_name, "--signing", "identity", "--web-engine", @tagName(web_engine), "--cef-dir", cef_dir }); + notarize_run.addFileArg(macos_distribution_executable); + notarize_run.addArgs(&.{ "--manifest", "app.zon", "--assets", "assets", "--optimize", optimize_name, "--signing", "identity", "--web-engine", @tagName(web_engine), "--cef-dir", cef_dir, "--archive", "--notarize" }); + if (signing_identity) |identity| notarize_run.addArgs(&.{ "--identity", identity }); + if (signing_entitlements) |entitlements| notarize_run.addArgs(&.{ "--entitlements", entitlements }); + if (notary_profile) |profile| notarize_run.addArgs(&.{ "--notary-profile", profile }); if (cef_auto_install) notarize_run.addArg("--cef-auto-install"); - notarize_run.step.dependOn(&embed_lib.step); + notarize_run.step.dependOn(&host_cli_exe.step); notarize_run.step.dependOn(&bundle_run.step); const notarize_step = b.step("notarize", "Package, sign with identity, and notarize for macOS distribution"); notarize_step.dependOn(¬arize_run.step); @@ -3118,6 +3134,8 @@ pub fn build(b: *std.Build) void { }); dmg_run.addFileArg(embed_lib.getEmittedBin()); dmg_run.addArgs(&.{ "--manifest", "app.zon", "--assets", "assets", "--optimize", optimize_name, "--signing", @tagName(signing_mode), "--web-engine", @tagName(web_engine), "--cef-dir", cef_dir, "--archive" }); + if (signing_identity) |identity| dmg_run.addArgs(&.{ "--identity", identity }); + if (signing_entitlements) |entitlements| dmg_run.addArgs(&.{ "--entitlements", entitlements }); if (cef_auto_install) dmg_run.addArg("--cef-auto-install"); dmg_run.step.dependOn(&embed_lib.step); dmg_run.step.dependOn(&bundle_run.step); diff --git a/docs/src/app/docs/cli/page.mdx b/docs/src/app/docs/cli/page.mdx index 96d752f87..20f57eb67 100644 --- a/docs/src/app/docs/cli/page.mdx +++ b/docs/src/app/docs/cli/page.mdx @@ -148,8 +148,10 @@ Package the app for distribution. The manifest is picked up at `app.json` (falli
Code signing identity name.
--entitlements
Path to entitlements file.
-
--team-id
-
Apple Developer Team ID.
+
--notarize
+
Submit the final macOS artifact to Apple's notary service, then staple and validate the accepted ticket. Requires identity signing.
+
--notary-profile
+
Name of a notarytool Keychain profile created with xcrun notarytool store-credentials.
--archive
Create a distributable archive. On macOS this is a styled DMG with the app, an Applications alias, a generated or custom background, and the Finder layout declared by the app manifest.
diff --git a/docs/src/app/docs/packaging/signing/page.mdx b/docs/src/app/docs/packaging/signing/page.mdx index 56a1e427f..cdf484251 100644 --- a/docs/src/app/docs/packaging/signing/page.mdx +++ b/docs/src/app/docs/packaging/signing/page.mdx @@ -10,6 +10,8 @@ Sign the bundle with a Developer ID: native package --target macos --signing identity --identity "Developer ID Application: Your Name" ``` +Identity signing enables the hardened runtime and explicitly requests Apple's secure timestamp. If the timestamp service cannot be reached, packaging fails instead of producing a distribution signature that notarization will reject. + Signing modes: @@ -69,25 +71,55 @@ For distribution without any dialog, sign with a Developer ID (`--signing identi - - + + + + + +
Path to entitlements file (e.g. assets/native-sdk.entitlements)
--team-idApple Developer Team ID--notarizeSubmit the final macOS artifact, then staple and validate the accepted ticket
--notary-profileName of a notarytool Keychain profile created with store-credentials
## Notarization -The framework repository includes a `zig build notarize` helper for local release testing: +Store notarization credentials in Keychain once. A team App Store Connect API key is the normal CI choice: + +```bash +xcrun notarytool store-credentials "my-app-release" \ + --key /path/to/AuthKey_KEYID.p8 \ + --key-id KEYID \ + --issuer ISSUER_UUID +``` + +Apple ID credentials work too: + +```bash +xcrun notarytool store-credentials "my-app-release" \ + --apple-id "you@example.com" \ + --team-id TEAMID \ + --password APP_SPECIFIC_PASSWORD +``` + +Then package, timestamp-sign, notarize, staple, and validate in one command: ```bash -zig build notarize +native package --target macos \ + --signing identity \ + --identity "Developer ID Application: Your Name (TEAMID)" \ + --archive \ + --notarize \ + --notary-profile "my-app-release" ``` -Generated apps should use `native package --target macos --signing identity ... --archive` unless they add their own `notarize` build step. This helper does not invoke `xcrun notarytool` directly. After the signed DMG is created, submit and staple it manually (use the archive path printed by the package command): +With `--archive`, Native SDK first submits the signed app, staples and validates it, then builds and signs the DMG and submits, staples, and validates that final image. Without `--archive`, only the app submission runs. Any updater ZIP is created afterward from the stapled app. + +The framework repository exposes the same path for local release testing: ```bash -xcrun notarytool submit zig-out/package/your-app-1.0.0-macos-ReleaseFast.dmg --apple-id "you@example.com" --team-id "TEAMID" --password "@keychain:AC_PASSWORD" --wait -xcrun stapler staple zig-out/package/your-app-1.0.0-macos-ReleaseFast.dmg +zig build notarize \ + -Didentity="Developer ID Application: Your Name (TEAMID)" \ + -Dnotary-profile="my-app-release" ``` ## Chromium apps diff --git a/examples/gpu-components/package.json b/examples/gpu-components/package.json index e8454badd..e9602c217 100644 --- a/examples/gpu-components/package.json +++ b/examples/gpu-components/package.json @@ -3,6 +3,6 @@ "private": true, "description": "Editor surface for the TypeScript core; the native CLI builds without node_modules.", "dependencies": { - "@native-sdk/core": "0.10.0" + "@native-sdk/core": "0.10.1" } } diff --git a/examples/kanban/package.json b/examples/kanban/package.json index 551c6654a..a0ed73bc0 100644 --- a/examples/kanban/package.json +++ b/examples/kanban/package.json @@ -3,6 +3,6 @@ "private": true, "description": "Editor surface for the TypeScript core; the native CLI builds without node_modules.", "dependencies": { - "@native-sdk/core": "0.10.0" + "@native-sdk/core": "0.10.1" } } diff --git a/examples/menu-bar/package.json b/examples/menu-bar/package.json index 7ad201dd4..83ba66958 100644 --- a/examples/menu-bar/package.json +++ b/examples/menu-bar/package.json @@ -3,6 +3,6 @@ "private": true, "description": "TypeScript + Native markup menu-bar lifecycle example.", "dependencies": { - "@native-sdk/core": "0.10.0" + "@native-sdk/core": "0.10.1" } } diff --git a/examples/record-store/package.json b/examples/record-store/package.json index 2264f502a..216a85340 100644 --- a/examples/record-store/package.json +++ b/examples/record-store/package.json @@ -3,6 +3,6 @@ "private": true, "description": "Editor surface for the TypeScript core; the native CLI builds without node_modules.", "dependencies": { - "@native-sdk/core": "0.10.0" + "@native-sdk/core": "0.10.1" } } diff --git a/examples/relational-notes/package.json b/examples/relational-notes/package.json index 60f2d6d9d..597fd8ce3 100644 --- a/examples/relational-notes/package.json +++ b/examples/relational-notes/package.json @@ -3,6 +3,6 @@ "private": true, "description": "Editor and versioning surface only: stock TypeScript tooling resolves @native-sdk/core from here. The native CLI never reads it and builds with node_modules absent.", "dependencies": { - "@native-sdk/core": "0.10.0" + "@native-sdk/core": "0.10.1" } } diff --git a/examples/soundboard-ts/package.json b/examples/soundboard-ts/package.json index 92a3267a2..af88b0069 100644 --- a/examples/soundboard-ts/package.json +++ b/examples/soundboard-ts/package.json @@ -3,6 +3,6 @@ "private": true, "description": "Editor and versioning surface only: stock TypeScript tooling resolves @native-sdk/core from here. The native CLI never reads it and builds with node_modules absent.", "dependencies": { - "@native-sdk/core": "0.10.0" + "@native-sdk/core": "0.10.1" } } diff --git a/examples/system-monitor-ts/package.json b/examples/system-monitor-ts/package.json index c2f1a2181..fd9801412 100644 --- a/examples/system-monitor-ts/package.json +++ b/examples/system-monitor-ts/package.json @@ -3,6 +3,6 @@ "private": true, "description": "Editor and versioning surface only: stock TypeScript tooling resolves @native-sdk/core from here. The native CLI never reads it and builds with node_modules absent.", "dependencies": { - "@native-sdk/core": "0.10.0" + "@native-sdk/core": "0.10.1" } } diff --git a/examples/voice-memo/package.json b/examples/voice-memo/package.json index f85685321..194a4f3c6 100644 --- a/examples/voice-memo/package.json +++ b/examples/voice-memo/package.json @@ -3,6 +3,6 @@ "private": true, "description": "Editor and versioning surface only: stock TypeScript tooling resolves @native-sdk/core from here. The native CLI never reads it and builds with node_modules absent.", "dependencies": { - "@native-sdk/core": "0.10.0" + "@native-sdk/core": "0.10.1" } } diff --git a/packages/core/package-lock.json b/packages/core/package-lock.json index 6fdda3886..d41e8ea4d 100644 --- a/packages/core/package-lock.json +++ b/packages/core/package-lock.json @@ -1,12 +1,12 @@ { "name": "@native-sdk/core", - "version": "0.10.0", + "version": "0.10.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@native-sdk/core", - "version": "0.10.0", + "version": "0.10.1", "dependencies": { "scriptc": "0.0.35" }, diff --git a/packages/core/package.json b/packages/core/package.json index 8dc144486..01ea5b2b7 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@native-sdk/core", - "version": "0.10.0", + "version": "0.10.1", "description": "The TypeScript authoring tier: the app-core subset, its checker and contract frontend, the exact-pinned core compiler dependency, and the SDK module cores import", "repository": { "type": "git", diff --git a/packages/native-sdk/npm/darwin-arm64/package.json b/packages/native-sdk/npm/darwin-arm64/package.json index 84d3b9428..030ca9349 100644 --- a/packages/native-sdk/npm/darwin-arm64/package.json +++ b/packages/native-sdk/npm/darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@native-sdk/cli-darwin-arm64", - "version": "0.10.0", + "version": "0.10.1", "description": "The native CLI binary for macOS on Apple silicon (arm64)", "os": [ "darwin" diff --git a/packages/native-sdk/npm/darwin-x64/package.json b/packages/native-sdk/npm/darwin-x64/package.json index c12f898a7..9fcb9c25e 100644 --- a/packages/native-sdk/npm/darwin-x64/package.json +++ b/packages/native-sdk/npm/darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "@native-sdk/cli-darwin-x64", - "version": "0.10.0", + "version": "0.10.1", "description": "The native CLI binary for macOS on Intel (x64)", "os": [ "darwin" diff --git a/packages/native-sdk/npm/linux-arm64-gnu/package.json b/packages/native-sdk/npm/linux-arm64-gnu/package.json index e43b6e888..9b85f82ec 100644 --- a/packages/native-sdk/npm/linux-arm64-gnu/package.json +++ b/packages/native-sdk/npm/linux-arm64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@native-sdk/cli-linux-arm64-gnu", - "version": "0.10.0", + "version": "0.10.1", "description": "The native CLI binary for Linux arm64 (glibc)", "os": [ "linux" diff --git a/packages/native-sdk/npm/linux-arm64-musl/package.json b/packages/native-sdk/npm/linux-arm64-musl/package.json index b85727251..5fc19febf 100644 --- a/packages/native-sdk/npm/linux-arm64-musl/package.json +++ b/packages/native-sdk/npm/linux-arm64-musl/package.json @@ -1,6 +1,6 @@ { "name": "@native-sdk/cli-linux-arm64-musl", - "version": "0.10.0", + "version": "0.10.1", "description": "The native CLI binary for Linux arm64 (musl)", "os": [ "linux" diff --git a/packages/native-sdk/npm/linux-x64-gnu/package.json b/packages/native-sdk/npm/linux-x64-gnu/package.json index f7ed863db..cbc8ba26f 100644 --- a/packages/native-sdk/npm/linux-x64-gnu/package.json +++ b/packages/native-sdk/npm/linux-x64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@native-sdk/cli-linux-x64-gnu", - "version": "0.10.0", + "version": "0.10.1", "description": "The native CLI binary for Linux x64 (glibc)", "os": [ "linux" diff --git a/packages/native-sdk/npm/linux-x64-musl/package.json b/packages/native-sdk/npm/linux-x64-musl/package.json index 7e65be07a..2bd66103a 100644 --- a/packages/native-sdk/npm/linux-x64-musl/package.json +++ b/packages/native-sdk/npm/linux-x64-musl/package.json @@ -1,6 +1,6 @@ { "name": "@native-sdk/cli-linux-x64-musl", - "version": "0.10.0", + "version": "0.10.1", "description": "The native CLI binary for Linux x64 (musl)", "os": [ "linux" diff --git a/packages/native-sdk/npm/win32-arm64/package.json b/packages/native-sdk/npm/win32-arm64/package.json index 499f49c38..bf17c369b 100644 --- a/packages/native-sdk/npm/win32-arm64/package.json +++ b/packages/native-sdk/npm/win32-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@native-sdk/cli-win32-arm64", - "version": "0.10.0", + "version": "0.10.1", "description": "The native CLI binary for Windows on ARM (arm64)", "os": [ "win32" diff --git a/packages/native-sdk/npm/win32-x64/package.json b/packages/native-sdk/npm/win32-x64/package.json index 85d8e9807..c1ad19df2 100644 --- a/packages/native-sdk/npm/win32-x64/package.json +++ b/packages/native-sdk/npm/win32-x64/package.json @@ -1,6 +1,6 @@ { "name": "@native-sdk/cli-win32-x64", - "version": "0.10.0", + "version": "0.10.1", "description": "The native CLI binary for Windows x64", "os": [ "win32" diff --git a/packages/native-sdk/package.json b/packages/native-sdk/package.json index 2820710bd..26aadd40f 100644 --- a/packages/native-sdk/package.json +++ b/packages/native-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@native-sdk/cli", - "version": "0.10.0", + "version": "0.10.1", "description": "The Native SDK: the complete toolkit for building native desktop applications — declarative markup, native rendering, WebView surfaces, and OS capabilities", "type": "module", "engines": { @@ -39,14 +39,14 @@ "scriptc": "0.0.35" }, "optionalDependencies": { - "@native-sdk/cli-darwin-arm64": "0.10.0", - "@native-sdk/cli-darwin-x64": "0.10.0", - "@native-sdk/cli-linux-arm64-gnu": "0.10.0", - "@native-sdk/cli-linux-arm64-musl": "0.10.0", - "@native-sdk/cli-linux-x64-gnu": "0.10.0", - "@native-sdk/cli-linux-x64-musl": "0.10.0", - "@native-sdk/cli-win32-arm64": "0.10.0", - "@native-sdk/cli-win32-x64": "0.10.0" + "@native-sdk/cli-darwin-arm64": "0.10.1", + "@native-sdk/cli-darwin-x64": "0.10.1", + "@native-sdk/cli-linux-arm64-gnu": "0.10.1", + "@native-sdk/cli-linux-arm64-musl": "0.10.1", + "@native-sdk/cli-linux-x64-gnu": "0.10.1", + "@native-sdk/cli-linux-x64-musl": "0.10.1", + "@native-sdk/cli-win32-arm64": "0.10.1", + "@native-sdk/cli-win32-x64": "0.10.1" }, "repository": { "type": "git", diff --git a/skill-data/core/references/web-engines-packaging-debugging.md b/skill-data/core/references/web-engines-packaging-debugging.md index b941f7cd2..1d60b8e17 100644 --- a/skill-data/core/references/web-engines-packaging-debugging.md +++ b/skill-data/core/references/web-engines-packaging-debugging.md @@ -106,9 +106,10 @@ Signing modes: native package --target macos --signing none native package --target macos --signing adhoc native package --target macos --signing identity --identity "Developer ID Application: Your Name" +native package --target macos --signing identity --identity "Developer ID Application: Your Name" --archive --notarize --notary-profile "my-app-release" ``` -For Chromium apps, verify the CEF framework and resources are included and signed before notarization. +Identity signing enables the hardened runtime and requires a secure timestamp. `--notarize` uses a `notarytool` Keychain profile, submits the final DMG when `--archive` is present, and staples and validates the accepted tickets. For Chromium apps, verify the CEF framework and resources are included and signed before notarization. ## Linux and Windows packages diff --git a/src/tooling/codesign.zig b/src/tooling/codesign.zig index d77f04ea0..ca88095c5 100644 --- a/src/tooling/codesign.zig +++ b/src/tooling/codesign.zig @@ -13,14 +13,13 @@ pub const CodesignArgs = struct { identity: []const u8 = "-", entitlements: ?[]const u8 = null, hardened_runtime: bool = false, + secure_timestamp: bool = false, deep: bool = true, }; pub const NotarizeArgs = struct { - app_path: []const u8, - team_id: []const u8, - apple_id: ?[]const u8 = null, - password_keychain_item: ?[]const u8 = null, + artifact_path: []const u8, + keychain_profile: []const u8, }; // Every command in this pipeline is built as an ARGV ARRAY, never as a @@ -29,7 +28,7 @@ pub const NotarizeArgs = struct { // replaced split `My App.app` into two arguments, codesign signed // nothing, and the failure never surfaced. -pub const sign_argv_capacity = 10; +pub const sign_argv_capacity = 11; pub fn signArgv(buffer: *[sign_argv_capacity][]const u8, args: CodesignArgs) []const []const u8 { var len: usize = 0; @@ -51,6 +50,10 @@ pub fn signArgv(buffer: *[sign_argv_capacity][]const u8, args: CodesignArgs) []c buffer[len] = "runtime"; len += 1; } + if (args.secure_timestamp) { + buffer[len] = "--timestamp"; + len += 1; + } if (args.entitlements) |ent| { buffer[len] = "--entitlements"; len += 1; @@ -67,6 +70,11 @@ pub fn verifyArgv(buffer: *[6][]const u8, app_path: []const u8) []const []const return buffer; } +pub fn verifyArtifactArgv(buffer: *[5][]const u8, artifact_path: []const u8) []const []const u8 { + buffer.* = .{ "codesign", "--verify", "--strict", "--verbose=2", artifact_path }; + return buffer; +} + pub fn zipArgv(buffer: *[6][]const u8, app_path: []const u8, zip_path: []const u8) []const []const u8 { buffer.* = .{ "ditto", "-c", "-k", "--keepParent", app_path, zip_path }; return buffer; @@ -77,37 +85,18 @@ pub fn stapleArgv(buffer: *[4][]const u8, app_path: []const u8) []const []const return buffer; } -pub const notarize_submit_argv_capacity = 11; +pub fn stapleValidateArgv(buffer: *[4][]const u8, app_path: []const u8) []const []const u8 { + buffer.* = .{ "xcrun", "stapler", "validate", app_path }; + return buffer; +} -pub fn notarizeSubmitArgv(buffer: *[notarize_submit_argv_capacity][]const u8, zip_path: []const u8, password_value: ?[]const u8, args: NotarizeArgs) []const []const u8 { - var len: usize = 0; - buffer[len] = "xcrun"; - len += 1; - buffer[len] = "notarytool"; - len += 1; - buffer[len] = "submit"; - len += 1; - buffer[len] = zip_path; - len += 1; - buffer[len] = "--team-id"; - len += 1; - buffer[len] = args.team_id; - len += 1; - if (args.apple_id) |apple_id| { - buffer[len] = "--apple-id"; - len += 1; - buffer[len] = apple_id; - len += 1; - } - if (password_value) |value| { - buffer[len] = "--password"; - len += 1; - buffer[len] = value; - len += 1; - } - buffer[len] = "--wait"; - len += 1; - return buffer[0..len]; +pub fn notarizeSubmitArgv(buffer: *[9][]const u8, artifact_path: []const u8, keychain_profile: []const u8) []const []const u8 { + buffer.* = .{ + "xcrun", "notarytool", "submit", + artifact_path, "--keychain-profile", keychain_profile, + "--wait", "--output-format", "json", + }; + return buffer; } pub fn signAdHoc(allocator: std.mem.Allocator, io: std.Io, app_path: []const u8) !SignResult { @@ -120,10 +109,20 @@ pub fn signIdentity(allocator: std.mem.Allocator, io: std.Io, app_path: []const .identity = identity, .entitlements = entitlements, .hardened_runtime = true, + .secure_timestamp = true, .deep = true, }); } +pub fn signIdentityArtifact(allocator: std.mem.Allocator, io: std.Io, artifact_path: []const u8, identity: []const u8) !SignResult { + return runSign(allocator, io, .{ + .app_path = artifact_path, + .identity = identity, + .secure_timestamp = true, + .deep = false, + }); +} + /// `codesign --verify --deep --strict` over the signed bundle: the same /// check Gatekeeper and an Apple silicon launch effectively run, so a /// signature that only LOOKS applied (stale seal, unsigned nested code) @@ -133,37 +132,58 @@ pub fn verify(allocator: std.mem.Allocator, io: std.Io, app_path: []const u8) !S return runTool(allocator, io, verifyArgv(&buffer, app_path)); } -pub fn notarize(allocator: std.mem.Allocator, io: std.Io, args: NotarizeArgs) !SignResult { - const zip_path = try std.fmt.allocPrint(allocator, "{s}.zip", .{args.app_path}); +pub fn verifyArtifact(allocator: std.mem.Allocator, io: std.Io, artifact_path: []const u8) !SignResult { + var buffer: [5][]const u8 = undefined; + return runTool(allocator, io, verifyArtifactArgv(&buffer, artifact_path)); +} + +pub fn notarizeApp(allocator: std.mem.Allocator, io: std.Io, args: NotarizeArgs) !SignResult { + const zip_path = try std.fmt.allocPrint(allocator, "{s}.notarize.zip", .{args.artifact_path}); defer allocator.free(zip_path); + defer std.Io.Dir.cwd().deleteFile(io, zip_path) catch {}; - { - var zip_buffer: [6][]const u8 = undefined; - const zip_result = try runTool(allocator, io, zipArgv(&zip_buffer, args.app_path, zip_path)); - if (!zip_result.ok) return failedStep(allocator, "ditto (zip for notarization)", zip_result); - allocator.free(zip_result.message); - } + var zip_buffer: [6][]const u8 = undefined; + const zip_result = try runTool(allocator, io, zipArgv(&zip_buffer, args.artifact_path, zip_path)); + if (!zip_result.ok) return failedStep(allocator, "ditto (zip for notarization)", zip_result); + allocator.free(zip_result.message); - { - const password_value: ?[]const u8 = if (args.password_keychain_item) |item| - try std.fmt.allocPrint(allocator, "@keychain:{s}", .{item}) - else - null; - defer if (password_value) |value| allocator.free(value); - var submit_buffer: [notarize_submit_argv_capacity][]const u8 = undefined; - const submit_result = try runTool(allocator, io, notarizeSubmitArgv(&submit_buffer, zip_path, password_value, args)); - if (!submit_result.ok) return failedStep(allocator, "notarytool submit", submit_result); - allocator.free(submit_result.message); - } + return notarizeArtifact(allocator, io, .{ + .artifact_path = args.artifact_path, + .keychain_profile = args.keychain_profile, + }, zip_path); +} - { - var staple_buffer: [4][]const u8 = undefined; - const staple_result = try runTool(allocator, io, stapleArgv(&staple_buffer, args.app_path)); - if (!staple_result.ok) return failedStep(allocator, "stapler staple", staple_result); - allocator.free(staple_result.message); - } +pub fn notarizeArtifact(allocator: std.mem.Allocator, io: std.Io, args: NotarizeArgs, submission_path: ?[]const u8) !SignResult { + var submit_buffer: [9][]const u8 = undefined; + const submit_result = try runTool(allocator, io, notarizeSubmitArgv(&submit_buffer, submission_path orelse args.artifact_path, args.keychain_profile)); + if (!submit_result.ok) return failedStep(allocator, "notarytool submit", submit_result); + if (!notarizationAccepted(allocator, submit_result.message)) return failedStep(allocator, "notarytool returned a non-accepted status", submit_result); + allocator.free(submit_result.message); - return .{ .ok = true, .message = try allocator.dupe(u8, "notarization complete") }; + var staple_buffer: [4][]const u8 = undefined; + const staple_result = try runTool(allocator, io, stapleArgv(&staple_buffer, args.artifact_path)); + if (!staple_result.ok) return failedStep(allocator, "stapler staple", staple_result); + allocator.free(staple_result.message); + + var validate_buffer: [4][]const u8 = undefined; + const validate_result = try runTool(allocator, io, stapleValidateArgv(&validate_buffer, args.artifact_path)); + if (!validate_result.ok) return failedStep(allocator, "stapler validate", validate_result); + allocator.free(validate_result.message); + + return .{ .ok = true, .message = try allocator.dupe(u8, "notarization accepted, stapled, and validated") }; +} + +fn notarizationAccepted(allocator: std.mem.Allocator, output: []const u8) bool { + const start = std.mem.indexOfScalar(u8, output, '{') orelse return false; + const end = std.mem.lastIndexOfScalar(u8, output, '}') orelse return false; + if (end < start) return false; + var parsed = std.json.parseFromSlice(std.json.Value, allocator, output[start .. end + 1], .{}) catch return false; + defer parsed.deinit(); + const status = switch (parsed.value) { + .object => |object| object.get("status") orelse return false, + else => return false, + }; + return status == .string and std.mem.eql(u8, status.string, "Accepted"); } fn failedStep(allocator: std.mem.Allocator, step: []const u8, result: SignResult) !SignResult { @@ -215,13 +235,30 @@ test "identity sign argv includes runtime and entitlements as single arguments" .identity = "Developer ID Application: Test Person (ABCD1234)", .entitlements = "assets dir/native-sdk.entitlements", .hardened_runtime = true, + .secure_timestamp = true, + }); + const expected = [_][]const u8{ + "codesign", "--sign", + "Developer ID Application: Test Person (ABCD1234)", "--force", + "--deep", "--options", + "runtime", "--timestamp", + "--entitlements", "assets dir/native-sdk.entitlements", + "/tmp/My Demo App.app", + }; + try expectArgv(&expected, argv); +} + +test "distribution artifact sign argv requests a secure timestamp without app-only options" { + var buffer: [sign_argv_capacity][]const u8 = undefined; + const argv = signArgv(&buffer, .{ + .app_path = "/tmp/My Demo App.dmg", + .identity = "Developer ID Application: Test Person (ABCD1234)", + .secure_timestamp = true, + .deep = false, }); const expected = [_][]const u8{ - "codesign", "--sign", - "Developer ID Application: Test Person (ABCD1234)", "--force", - "--deep", "--options", - "runtime", "--entitlements", - "assets dir/native-sdk.entitlements", "/tmp/My Demo App.app", + "codesign", "--sign", "Developer ID Application: Test Person (ABCD1234)", + "--force", "--timestamp", "/tmp/My Demo App.dmg", }; try expectArgv(&expected, argv); } @@ -233,14 +270,17 @@ test "verify argv runs the strict deep check on the bundle path" { try expectArgv(&expected, argv); } -test "notarize submit argv includes team id, keychain password, and wait" { - var buffer: [notarize_submit_argv_capacity][]const u8 = undefined; - const argv = notarizeSubmitArgv(&buffer, "/tmp/My Demo App.app.zip", "@keychain:AC_PASSWORD", .{ - .app_path = "/tmp/My Demo App.app", - .team_id = "ABCD1234", - .apple_id = "dev@example.com", - }); - const expected = [_][]const u8{ "xcrun", "notarytool", "submit", "/tmp/My Demo App.app.zip", "--team-id", "ABCD1234", "--apple-id", "dev@example.com", "--password", "@keychain:AC_PASSWORD", "--wait" }; +test "artifact verify argv runs the strict check without bundle recursion" { + var buffer: [5][]const u8 = undefined; + const argv = verifyArtifactArgv(&buffer, "/tmp/My Demo App.dmg"); + const expected = [_][]const u8{ "codesign", "--verify", "--strict", "--verbose=2", "/tmp/My Demo App.dmg" }; + try expectArgv(&expected, argv); +} + +test "notarize submit argv uses a keychain profile, waits, and requests JSON" { + var buffer: [9][]const u8 = undefined; + const argv = notarizeSubmitArgv(&buffer, "/tmp/My Demo App.dmg", "vercel-release"); + const expected = [_][]const u8{ "xcrun", "notarytool", "submit", "/tmp/My Demo App.dmg", "--keychain-profile", "vercel-release", "--wait", "--output-format", "json" }; try expectArgv(&expected, argv); } @@ -250,12 +290,23 @@ test "staple and zip argv carry spaced paths as single arguments" { const staple_expected = [_][]const u8{ "xcrun", "stapler", "staple", "/tmp/My Demo App.app" }; try expectArgv(&staple_expected, staple); + var validate_buffer: [4][]const u8 = undefined; + const validate = stapleValidateArgv(&validate_buffer, "/tmp/My Demo App.app"); + const validate_expected = [_][]const u8{ "xcrun", "stapler", "validate", "/tmp/My Demo App.app" }; + try expectArgv(&validate_expected, validate); + var zip_buffer: [6][]const u8 = undefined; const zip = zipArgv(&zip_buffer, "/tmp/My Demo App.app", "/tmp/My Demo App.app.zip"); const zip_expected = [_][]const u8{ "ditto", "-c", "-k", "--keepParent", "/tmp/My Demo App.app", "/tmp/My Demo App.app.zip" }; try expectArgv(&zip_expected, zip); } +test "notarization accepts only an explicit Accepted JSON status" { + try std.testing.expect(notarizationAccepted(std.testing.allocator, "{\"id\":\"123\",\"status\":\"Accepted\"}\n")); + try std.testing.expect(!notarizationAccepted(std.testing.allocator, "{\"id\":\"123\",\"status\":\"Invalid\"}\n")); + try std.testing.expect(!notarizationAccepted(std.testing.allocator, "not JSON")); +} + test "a failing tool surfaces its own output, not a silent success" { if (@import("builtin").os.tag == .windows) return error.SkipZigTest; // `false` exits 1 with no output: the result must be a failure. diff --git a/src/tooling/package.zig b/src/tooling/package.zig index 937d9b154..1f12e6c12 100644 --- a/src/tooling/package.zig +++ b/src/tooling/package.zig @@ -54,7 +54,6 @@ pub const SigningConfig = struct { identity: ?[]const u8 = null, entitlements: ?[]const u8 = null, profile: ?[]const u8 = null, - team_id: ?[]const u8 = null, }; pub const PackageOptions = struct { @@ -83,6 +82,10 @@ pub const PackageOptions = struct { cef_dir: []const u8 = web_engine_tool.default_cef_dir, signing: SigningConfig = .{}, archive: bool = false, + /// Submit the final macOS distribution artifact to Apple's notary service, + /// then staple and validate its ticket. Requires identity signing and a + /// notarytool Keychain profile in `signing.profile`. + notarize: bool = false, /// Emit the ZIP consumed by the native updater. macOS only; unlike the /// user-facing DMG, this archive contains exactly the packaged .app. update_archive: bool = false, @@ -152,6 +155,7 @@ pub const PackageStats = struct { /// the proof behind the report's "signed, verified" line. A package /// whose signing or verification fails never produces stats at all. signing_verified: bool = false, + notarized: bool = false, asset_count: usize = 0, web_engine: WebEngine = .system, web_layer: ?manifest_tool.WebLayer = null, @@ -221,6 +225,20 @@ pub fn createPackage(allocator: std.mem.Allocator, io: std.Io, options: PackageO return err; }; try validateWebEngineTarget(options.target, options.web_engine); + if (options.notarize) { + if (options.target != .macos) { + std.debug.print("error: notarization is supported only for macOS packages\n", .{}); + return error.UnsupportedNotarizationTarget; + } + if (options.signing.mode != .identity) { + std.debug.print("error: --notarize requires --signing identity and a Developer ID Application certificate\n", .{}); + return error.NotarizationRequiresIdentity; + } + if (options.signing.profile == null) { + std.debug.print("error: --notarize requires --notary-profile ; create it with `xcrun notarytool store-credentials `\n", .{}); + return error.MissingNotaryProfile; + } + } if (options.metadata.updates.enabled() and options.target == .macos and options.web_engine == .chromium) { std.debug.print("error: native updates currently require the system macOS host; package with --web-engine system or remove the updates block\n", .{}); return error.UnsupportedUpdateHost; @@ -241,12 +259,24 @@ pub fn createPackage(allocator: std.mem.Allocator, io: std.Io, options: PackageO .ios => try createIosArtifact(allocator, io, options), .android => try createAndroidArtifact(allocator, io, options), }; + if (options.notarize) { + try runNotarization(allocator, io, options.output_path, options.signing.profile.?, true); + } if (options.archive) { const archive_path = try createArchive(allocator, io, options); if (archive_path) |path| { stats.archive_path = path; + if (options.target == .macos and options.signing.mode == .identity) { + try signDistributionArtifact(allocator, io, path, options.signing.identity.?); + } + if (options.notarize) { + try runNotarization(allocator, io, path, options.signing.profile.?, false); + } } } + if (options.notarize) { + stats.notarized = true; + } if (options.update_archive) { if (options.target != .macos) return error.UnsupportedUpdateTarget; if (!options.metadata.updates.enabled()) return error.UpdatesNotConfigured; @@ -286,6 +316,9 @@ pub fn printDiagnostic(stats: PackageStats) void { if (stats.signing_verified) { std.debug.print(" signing: {s} (signed, verified)\n", .{@tagName(stats.signing_mode)}); } + if (stats.notarized) { + std.debug.print(" notarization: accepted, stapled, validated\n", .{}); + } if (stats.windows_subsystem) |subsystem| { switch (subsystem) { .console => std.debug.print(" subsystem: console (a terminal window opens behind the app - rebuild with `native build`)\n", .{}), @@ -1721,6 +1754,33 @@ fn runSigning(allocator: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, options return true; } +fn signDistributionArtifact(allocator: std.mem.Allocator, io: std.Io, path: []const u8, identity: []const u8) !void { + const signed = try codesign.signIdentityArtifact(allocator, io, path, identity); + defer allocator.free(signed.message); + if (!signed.ok) { + std.debug.print("error: code signing distribution artifact {s} with \"{s}\" failed:\n{s}\n", .{ path, identity, trimmedToolOutput(signed.message) }); + return error.SigningFailed; + } + const verified = try codesign.verifyArtifact(allocator, io, path); + defer allocator.free(verified.message); + if (!verified.ok) { + std.debug.print("error: signed distribution artifact {s} failed strict codesign verification:\n{s}\n", .{ path, trimmedToolOutput(verified.message) }); + return error.SignatureVerificationFailed; + } +} + +fn runNotarization(allocator: std.mem.Allocator, io: std.Io, path: []const u8, profile: []const u8, app_bundle: bool) !void { + const result = if (app_bundle) + try codesign.notarizeApp(allocator, io, .{ .artifact_path = path, .keychain_profile = profile }) + else + try codesign.notarizeArtifact(allocator, io, .{ .artifact_path = path, .keychain_profile = profile }, null); + defer allocator.free(result.message); + if (!result.ok) { + std.debug.print("error: notarization failed for {s}:\n{s}\n", .{ path, trimmedToolOutput(result.message) }); + return error.NotarizationFailed; + } +} + /// codesign's output, trimmed of trailing newlines so the teaching /// message's fix line lands directly under it (the output itself stays /// verbatim). @@ -3630,6 +3690,30 @@ test "identity signing without an identity is a loud failure, not a silent unsig })); } +test "notarization refuses unsigned non-macos and uncredentialed packages before artifact creation" { + const metadata: manifest_tool.Metadata = .{ .id = "dev.example.notarize", .name = "notarize-demo", .version = "1.0.0" }; + try std.testing.expectError(error.NotarizationRequiresIdentity, createPackage(std.testing.allocator, std.testing.io, .{ + .metadata = metadata, + .target = .macos, + .output_path = ".zig-cache/test-notarize-unsigned.app", + .notarize = true, + })); + try std.testing.expectError(error.UnsupportedNotarizationTarget, createPackage(std.testing.allocator, std.testing.io, .{ + .metadata = metadata, + .target = .linux, + .output_path = ".zig-cache/test-notarize-linux", + .signing = .{ .mode = .identity, .identity = "Developer ID Application: Test" }, + .notarize = true, + })); + try std.testing.expectError(error.MissingNotaryProfile, createPackage(std.testing.allocator, std.testing.io, .{ + .metadata = metadata, + .target = .macos, + .output_path = ".zig-cache/test-notarize-no-profile.app", + .signing = .{ .mode = .identity, .identity = "Developer ID Application: Test" }, + .notarize = true, + })); +} + test "native-only windows package ships no WebView2 loader and reports web layer none" { var cwd = std.Io.Dir.cwd(); const root = ".zig-cache/test-package-native-only-windows"; diff --git a/tools/native-sdk/main.zig b/tools/native-sdk/main.zig index 33fd05d31..78bcd788e 100644 --- a/tools/native-sdk/main.zig +++ b/tools/native-sdk/main.zig @@ -6,7 +6,7 @@ const tooling = @import("tooling"); const automation_protocol = @import("automation_protocol"); const cli_build_info = @import("cli_build_info"); -const version = "0.10.0"; +const version = "0.10.1"; pub fn main(init: std.process.Init) !void { const allocator = init.arena.allocator(); @@ -204,9 +204,9 @@ pub fn main(init: std.process.Init) !void { std.debug.print("bundled {d} assets into {s}\n", .{ stats.asset_count, output_dir }); } else if (std.mem.eql(u8, command, "package")) { checkVerbFlags("package", args[2..], .{ - .usage = "package [--target macos] [--output path] [--binary path] [--service-binary path] [--assets path] [--web-engine system|chromium] [--web-layer auto|include|exclude] [--cef-dir path] [--cef-auto-install] [--signing none|adhoc|identity] [--identity name] [--entitlements path] [--team-id id] [--archive] [--update-archive]", - .value_flags = &.{ "--manifest", "--target", "--output", "--binary", "--service-binary", "--assets", "--web-engine", "--web-layer", "--cef-dir", "--signing", "--identity", "--entitlements", "--team-id", "--optimize" }, - .bool_flags = &.{ "--cef-auto-install", "--archive", "--update-archive" }, + .usage = "package [--target macos] [--output path] [--binary path] [--service-binary path] [--assets path] [--web-engine system|chromium] [--web-layer auto|include|exclude] [--cef-dir path] [--cef-auto-install] [--signing none|adhoc|identity] [--identity name] [--entitlements path] [--notarize --notary-profile name] [--archive] [--update-archive]", + .value_flags = &.{ "--manifest", "--target", "--output", "--binary", "--service-binary", "--assets", "--web-engine", "--web-layer", "--cef-dir", "--signing", "--identity", "--entitlements", "--notary-profile", "--optimize" }, + .bool_flags = &.{ "--cef-auto-install", "--notarize", "--archive", "--update-archive" }, }); const manifest_path = try flagValue(args, "--manifest") orelse tooling.manifest.defaultPath(init.io) orelse "app.json"; const metadata = tooling.manifest.readMetadata(allocator, init.io, manifest_path) catch |err| switch (err) { @@ -279,8 +279,9 @@ pub fn main(init: std.process.Init) !void { .web_engine = web_engine.engine, .web_layer_setting = web_layer_setting, .cef_dir = web_engine.cef_dir, - .signing = .{ .mode = signing, .identity = try flagValue(args, "--identity"), .entitlements = try flagValue(args, "--entitlements"), .team_id = try flagValue(args, "--team-id") }, + .signing = .{ .mode = signing, .identity = try flagValue(args, "--identity"), .entitlements = try flagValue(args, "--entitlements"), .profile = try flagValue(args, "--notary-profile") }, .archive = archive, + .notarize = flagBool(args, "--notarize"), .update_archive = flagBool(args, "--update-archive"), .env_map = init.environ_map, }); @@ -484,7 +485,7 @@ fn usage() void { \\ doctor [--strict] [--manifest app.json] [--web-engine system|chromium] [--cef-dir path] [--cef-auto-install] \\ validate [app.json|app.zon] \\ bundle-assets [app.json|app.zon] [assets] [output] - \\ package [--target macos|windows|linux|ios|android] [--output path] [--binary path] [--service-binary path] [--assets path] [--web-engine system|chromium] [--web-layer auto|include|exclude] [--cef-dir path] [--cef-auto-install] [--signing none|adhoc|identity] [--identity name] [--entitlements path] [--team-id id] [--archive] [--update-archive] + \\ package [--target macos|windows|linux|ios|android] [--output path] [--binary path] [--service-binary path] [--assets path] [--web-engine system|chromium] [--web-layer auto|include|exclude] [--cef-dir path] [--cef-auto-install] [--signing none|adhoc|identity] [--identity name] [--entitlements path] [--notarize --notary-profile name] [--archive] [--update-archive] \\ dev [--manifest app.json] --binary path [--url http://127.0.0.1:5173/] [--command "npm run dev"] [--timeout-ms 30000] \\ package-windows [--output path] [--binary path] [--service-binary path] \\ package-linux [--output path] [--binary path] [--service-binary path] @@ -974,7 +975,7 @@ fn positionalArg(args: []const []const u8) ?[]const u8 { std.mem.eql(u8, arg, "--signing") or std.mem.eql(u8, arg, "--identity") or std.mem.eql(u8, arg, "--entitlements") or - std.mem.eql(u8, arg, "--team-id") or + std.mem.eql(u8, arg, "--notary-profile") or std.mem.eql(u8, arg, "--command") or std.mem.eql(u8, arg, "--url") or std.mem.eql(u8, arg, "--timeout-ms") or