Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions Documentation/xtool.docc/Distribution.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Sign for distribution

Produce an `.ipa` for TestFlight, the App Store or Ad Hoc distribution, entirely offline.

## Overview

`xtool dev` normally signs your app with a development certificate that xtool creates for you through your Apple ID. That is what you want for running on your own device, but an App Store or Ad Hoc build needs a **distribution** certificate and a matching provisioning profile.

xtool can sign with a distribution identity you already have. It never contacts Apple in this mode: it doesn't create, renew or revoke any certificate or profile, so it is safe to run in CI.

You need:

- An **Apple Distribution** certificate with its private key, exported as a `.p12` file (in Keychain Access, or with `openssl pkcs12 -export`).
- An **App Store** or **Ad Hoc** provisioning profile (`.mobileprovision`) for your app's bundle ID, created with that certificate on the Apple Developer portal.

## Sign a build

Pass the identity and the profile to `xtool dev build`:

```bash
export XTOOL_P12_PASSWORD='<password>'
xtool dev build --configuration release --ipa \
--p12 Distribution.p12 \
--profile MyApp_AppStore.mobileprovision
```

The password is read from the `XTOOL_P12_PASSWORD` environment variable, or from a file with `--p12-password-file`. Use `--p12-password-env` to read a different variable. It is deliberately not accepted as a command-line argument, since arguments are visible to other users of the machine and end up in shell history and CI logs.

## Re-sign an existing `.ipa`

`xtool sign` re-signs an `.ipa` (or an `.app`) that was built elsewhere, and writes a new `.ipa`. The input is left untouched.

```bash
xtool sign MyApp.ipa \
--p12 Distribution.p12 \
--profile MyApp_AppStore.mobileprovision \
--output MyApp-signed.ipa
```

## What gets signed

xtool checks the inputs before it signs anything, and refuses to continue if:

- the certificate is not a distribution certificate, or has expired;
- the profile is a development profile, has expired, belongs to another team, or doesn't include your certificate;
- no profile matches the app's bundle ID.

The entitlements are derived from the profile, so that the signature matches what the profile allows:

- `application-identifier` and `com.apple.developer.team-identifier` always come from the profile.
- `get-task-allow` is always `false`.
- Like Xcode, xtool claims what the app asks for (the entitlements already in its signature, for instance those from your `.entitlements` file), checked against what the profile allows. Asking for something the profile doesn't allow is an error. `beta-reports-active` is carried over when the profile has it.
- If the app has no entitlements of its own, everything the profile grants is claimed, such as `aps-environment` (`production` in a distribution profile) and app groups.
- Wildcards in the profile are resolved for your app. For example, `keychain-access-groups` of `<team>.*` becomes your app's own group.

To use a specific set of entitlements instead of the ones already in the app, pass `--entitlements Entitlements.plist`.

Extensions (`.appex`) are signed with their own profile and entitlements. Pass one `--profile` per bundle: each bundle uses the profile that best matches its bundle ID.

The profile is copied into each bundle as `embedded.mobileprovision`.
1 change: 1 addition & 0 deletions Documentation/xtool.docc/xtool.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,4 @@ xtool is a cross-platform (Linux/WSL/macOS) tool that replicates Xcode functiona

- <doc:Control>
- <doc:Appex>
- <doc:Distribution>
5 changes: 2 additions & 3 deletions Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,9 @@ let package = Package(
dependencies: [
.package(url: "https://github.com/xtool-org/xtool-core", .upToNextMinor(from: "1.5.0")),
.package(url: "https://github.com/xtool-org/SwiftyMobileDevice", .upToNextMinor(from: "1.7.0")),
.package(url: "https://github.com/xtool-org/zsign", .upToNextMinor(from: "1.9.0")),
// TODO: temporary, points at a branch combining xtool-org/zsign#3, #4 and #5. Switch to
// .upToNextMinor(from: "<tag>") once zsign tags a release including them.
.package(url: "https://github.com/TartanLeGrand/zsign", revision: "4d2eb4c07e26cf56b6a161fa4a1c4eb4eb56cbc2"),
.package(url: "https://github.com/xtool-org/xadi", .upToNextMinor(from: "0.4.2")),

.package(url: "https://github.com/apple/swift-system", from: "1.4.0"),
Expand Down
26 changes: 26 additions & 0 deletions Sources/CXKit/include/pkcs12_identity.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
#ifndef pkcs12_identity_h
#define pkcs12_identity_h

#include <stddef.h>

#pragma clang assume_nonnull begin

typedef enum {
PKCS12_IDENTITY_OK = 0,
PKCS12_IDENTITY_INVALID = 1,
PKCS12_IDENTITY_BAD_PASSWORD = 2,
PKCS12_IDENTITY_MISSING_CONTENT = 3,
} pkcs12_identity_status_t;

/// Extracts the leaf certificate (DER) and its private key (unencrypted PKCS#8 PEM)
/// from a PKCS#12 archive. On success both outputs are malloc'd and owned by the caller.
pkcs12_identity_status_t pkcs12_identity_extract(
const void *data, size_t len,
const char *password,
void * _Nullable * _Nonnull cert_der, size_t *cert_len,
void * _Nullable * _Nonnull key_pem, size_t *key_len
);

#pragma clang assume_nonnull end

#endif /* pkcs12_identity_h */
100 changes: 100 additions & 0 deletions Sources/CXKit/pkcs12_identity.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
#include <pthread.h>
#include <stdlib.h>
#include <string.h>
#include <openssl/bio.h>
#include <openssl/err.h>
#include <openssl/pem.h>
#include <openssl/pkcs12.h>
#include <openssl/provider.h>
#include <openssl/x509.h>
#include "pkcs12_identity.h"

static pthread_once_t providers_once = PTHREAD_ONCE_INIT;

static void load_providers_once(void) {
// archives exported by older keychains use RC2/3DES, which OpenSSL 3 only
// provides through the legacy provider. It's fine if it isn't available.
OSSL_PROVIDER_load(NULL, "default");
OSSL_PROVIDER_load(NULL, "legacy");
}

static void load_providers(void) {
// loading providers concurrently races with PKCS12_parse on other threads
// (seen as a parse that succeeds without a key or certificate), so do it once
pthread_once(&providers_once, load_providers_once);
}

pkcs12_identity_status_t pkcs12_identity_extract(
const void *data, size_t len,
const char *password,
void **cert_der, size_t *cert_len,
void **key_pem, size_t *key_len
) {
*cert_der = NULL;
*key_pem = NULL;
*cert_len = 0;
*key_len = 0;

load_providers();
ERR_clear_error();

pkcs12_identity_status_t status = PKCS12_IDENTITY_INVALID;
PKCS12 *p12 = NULL;
EVP_PKEY *pkey = NULL;
X509 *cert = NULL;
STACK_OF(X509) *ca = NULL;
BIO *mem = NULL;

const unsigned char *cursor = data;
p12 = d2i_PKCS12(NULL, &cursor, (long)len);
if (!p12) goto done;

if (!PKCS12_parse(p12, password, &pkey, &cert, &ca)) {
unsigned long err = ERR_peek_last_error();
if (ERR_GET_LIB(err) == ERR_LIB_PKCS12 && ERR_GET_REASON(err) == PKCS12_R_MAC_VERIFY_FAILURE) {
status = PKCS12_IDENTITY_BAD_PASSWORD;
}
goto done;
}
if (!pkey || !cert) {
status = PKCS12_IDENTITY_MISSING_CONTENT;
goto done;
}

unsigned char *der = NULL;
int der_len = i2d_X509(cert, &der);
if (der_len <= 0) goto done;

mem = BIO_new(BIO_s_mem());
if (!mem || !PEM_write_bio_PKCS8PrivateKey(mem, pkey, NULL, NULL, 0, NULL, NULL)) {
OPENSSL_free(der);
goto done;
}
char *pem = NULL;
long pem_len = BIO_get_mem_data(mem, &pem);

*cert_der = malloc((size_t)der_len);
*key_pem = malloc((size_t)pem_len);
if (!*cert_der || !*key_pem) {
free(*cert_der);
free(*key_pem);
*cert_der = NULL;
*key_pem = NULL;
OPENSSL_free(der);
goto done;
}
memcpy(*cert_der, der, (size_t)der_len);
memcpy(*key_pem, pem, (size_t)pem_len);
*cert_len = (size_t)der_len;
*key_len = (size_t)pem_len;
OPENSSL_free(der);
status = PKCS12_IDENTITY_OK;

done:
BIO_free_all(mem);
sk_X509_pop_free(ca, X509_free);
X509_free(cert);
EVP_PKEY_free(pkey);
PKCS12_free(p12);
return status;
}
11 changes: 11 additions & 0 deletions Sources/XKit/Model/Certificate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,17 @@ public struct Certificate: Codable, Sendable {
return String(content)
}

public var expirationDate: Date {
raw.notValidAfter
}

/// Whether the certificate is an Apple Distribution (or legacy iPhone Distribution) identity,
/// as opposed to a development one. Apple encodes this in the common name.
public func isDistribution() throws -> Bool {
let name = try developerIdentity()
return name.hasPrefix("Apple Distribution:") || name.hasPrefix("iPhone Distribution:")
}

public func wasIssuedBefore(_ other: Certificate) -> Bool {
raw.notValidBefore < other.raw.notValidBefore
}
Expand Down
46 changes: 45 additions & 1 deletion Sources/XKit/Model/Mobileprovision.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,26 @@ public final class Mobileprovision: @unchecked Sendable {
case invalidProfile
}

public struct Digest: Decodable {
/// The distribution method a profile is meant for, as Xcode's export dialog would call it.
public enum Kind: Sendable, Equatable {
case development
case adHoc
case appStore
case enterprise
}

public struct Digest: Decodable, Sendable {
public let name: String
public let uuid: String
public let teamIdentifiers: [DeveloperServicesTeam.ID]
public let creationDate: Date
public let expirationDate: Date
/// The UDIDs the profile is restricted to. Empty for App Store and Enterprise profiles.
public let devices: [String]
public let platforms: [String]
public let certificates: [Certificate]
public let entitlements: Entitlements
public let kind: Kind

private enum CodingKeys: String, CodingKey {
case name = "Name"
Expand All @@ -32,9 +43,38 @@ public final class Mobileprovision: @unchecked Sendable {
case creationDate = "CreationDate"
case expirationDate = "ExpirationDate"
case devices = "ProvisionedDevices"
case provisionsAllDevices = "ProvisionsAllDevices"
case platforms = "Platform"
case certificates = "DeveloperCertificates"
case entitlements = "Entitlements"
}

public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
name = try container.decode(String.self, forKey: .name)
uuid = try container.decode(String.self, forKey: .uuid)
teamIdentifiers = try container.decode([DeveloperServicesTeam.ID].self, forKey: .teamIdentifiers)
creationDate = try container.decode(Date.self, forKey: .creationDate)
expirationDate = try container.decode(Date.self, forKey: .expirationDate)
// App Store and Enterprise profiles have no ProvisionedDevices key
let provisionedDevices = try container.decodeIfPresent([String].self, forKey: .devices)
devices = provisionedDevices ?? []
platforms = try container.decodeIfPresent([String].self, forKey: .platforms) ?? []
certificates = try container.decode([Certificate].self, forKey: .certificates)
entitlements = try container.decode(Entitlements.self, forKey: .entitlements)

let provisionsAllDevices = try container.decodeIfPresent(Bool.self, forKey: .provisionsAllDevices) ?? false
if provisionsAllDevices {
kind = .enterprise
} else if provisionedDevices == nil {
kind = .appStore
} else {
let getTaskAllow = try entitlements.entitlements()
.compactMap { $0 as? GetTaskAllowEntitlement }
.first?.rawValue ?? false
kind = getTaskAllow ? .development : .adHoc
}
}
}

let raw: mobileprovision_t
Expand All @@ -53,6 +93,10 @@ public final class Mobileprovision: @unchecked Sendable {
mobileprovision_free(raw)
}

public convenience init(contentsOf url: URL) throws {
try self.init(data: Data(contentsOf: url))
}

public func digest() throws -> Digest {
var len = 0
guard let ptr = mobileprovision_get_digest(raw, &len), len > 0 else {
Expand Down
Loading