diff --git a/.gitignore b/.gitignore index a7cce9e..2f8eeaa 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ __pycache__/ # Do not store AI Agent instructions AGENTS.md CLAUDE.md +.claude/ \ No newline at end of file diff --git a/README.md b/README.md index f259fff..13abcc1 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,91 @@ simple-ca make-cert [--ca-dir DIR] [--days N] [--issuing-ca PREFIX] CERT_DIR SUB simple-ca make-pfx --ca-dir DIR [--issuing-ca PREFIX] --path CERT_PATH [--password PASS] ``` +## generate-mobileconfig.py + +`generate-mobileconfig.py` generates Apple `.mobileconfig` profiles for distributing CA certificates and optionally client certificates and IKEv2 VPN configuration to Apple devices (macOS / iOS / iPadOS). + +### Modes + +| Arguments supplied | Profile content | +|---|---| +| `--ca-cert` only | CA trust anchor | +| `--ca-cert` + `--client-cert` + `--client-key` | CA trust anchor + PKCS#12 client certificate | +| All of the above + `--remote-address` + `--match-domains` | CA + client cert + IKEv2 VPN | + +### Usage + +``` +generate-mobileconfig.py --ca-cert CA.pem --output profile.mobileconfig \ + --identifier com.example.vpn \ + [--client-cert CLIENT.pem --client-key CLIENT_KEY.pem] \ + [--remote-address vpn.example.com --match-domains example.com] \ + [--profile-name "My VPN"] [--ca-name "My CA"] \ + [--client-name "My Cert"] [--vpn-name "My VPN Connection"] \ + [--openssl /usr/bin/openssl] +``` + +#### Required arguments + +- `--ca-cert PEM` — CA certificate PEM file to embed as a trust anchor. +- `--output FILE` — Output `.mobileconfig` path. +- `--identifier ID` — Reverse-DNS profile identifier (e.g. `com.example.vpn`). Derived automatically from `--remote-address` when a VPN profile is generated. + +#### Client certificate (optional) + +- `--client-cert PEM` — Client certificate PEM file. +- `--client-key PEM` — Client private key PEM file (required together with `--client-cert`). + +#### VPN (requires client certificate) + +- `--remote-address FQDN` — VPN gateway hostname. +- `--match-domains DOMAIN [DOMAIN …]` — Split-DNS domains routed through the VPN. + +#### Display name overrides (all optional) + +- `--profile-name NAME` — Profile display name (default: `VPN` or `Certificates`). +- `--ca-name NAME` — CA payload display name (default: certificate CN). +- `--client-name NAME` — Client cert payload display name (default: certificate CN). +- `--vpn-name NAME` — VPN connection display name (default: profile name). + +#### Other + +- `--openssl PATH` — Path to the `openssl` binary (default: `/usr/bin/openssl`). + +### Examples + +**CA trust profile only:** + +```bash +python3 generate-mobileconfig.py \ + --ca-cert ca/ca_cert.pem \ + --identifier com.example.ca \ + --output ca-trust.mobileconfig +``` + +**CA + client certificate:** + +```bash +python3 generate-mobileconfig.py \ + --ca-cert ca/ca_cert.pem \ + --client-cert certs/alice_cert.pem \ + --client-key certs/alice_key.pem \ + --identifier com.example.certs \ + --output alice.mobileconfig +``` + +**Full IKEv2 VPN profile:** + +```bash +python3 generate-mobileconfig.py \ + --ca-cert ca/ca_cert.pem \ + --client-cert certs/alice_cert.pem \ + --client-key certs/alice_key.pem \ + --remote-address vpn.example.com \ + --match-domains example.com internal.example.com \ + --output alice-vpn.mobileconfig +``` + ## Self Signed Ceritifcate The following command will create a *full-featured* self-signed certificate that can act as CA certificate and be used for client and server authentication: diff --git a/generate-mobileconfig.py b/generate-mobileconfig.py new file mode 100644 index 0000000..b19af41 --- /dev/null +++ b/generate-mobileconfig.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +"""Generate an Apple mobileconfig profile. + +Modes (determined by arguments provided): + CA only --ca-cert + CA + client --ca-cert --client-cert --client-key + Full VPN --ca-cert --client-cert --client-key --remote-address --match-domains +""" + +import argparse +import plistlib +import secrets +import subprocess +import tempfile +import uuid +from pathlib import Path + +OPENSSL = "/usr/bin/openssl" + + +def openssl(*args) -> bytes: + return subprocess.run([OPENSSL, *args], capture_output=True, check=True).stdout + + +def cert_der(pem_path: Path) -> bytes: + return openssl("x509", "-in", str(pem_path), "-outform", "DER") + + +def cert_cn(pem_path: Path) -> str: + out = openssl("x509", "-noout", "-subject", "-nameopt", "multiline", "-in", str(pem_path)) + for line in out.decode().splitlines(): + line = line.strip() + if line.startswith("commonName"): + return line.split("=", 1)[1].strip() + return pem_path.stem + + +def build_p12(cert_path: Path, key_path: Path) -> tuple[bytes, str]: + password = secrets.token_urlsafe(32) + with tempfile.NamedTemporaryFile(suffix=".p12", delete=False) as tmp: + tmp_path = Path(tmp.name) + try: + openssl( + "pkcs12", "-export", + "-in", str(cert_path), + "-inkey", str(key_path), + "-out", str(tmp_path), + "-passout", f"pass:{password}", + ) + return tmp_path.read_bytes(), password + finally: + tmp_path.unlink(missing_ok=True) + + +def main(): + global OPENSSL + + parser = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + + g_ca = parser.add_argument_group("CA certificate (required)") + g_ca.add_argument("--ca-cert", required=True, metavar="PEM", help="CA certificate PEM file") + + g_client = parser.add_argument_group("Client certificate (optional)") + g_client.add_argument("--client-cert", metavar="PEM", help="Client certificate PEM file") + g_client.add_argument("--client-key", metavar="PEM", help="Client private key PEM file") + + g_vpn = parser.add_argument_group("VPN (optional, requires client certificate)") + g_vpn.add_argument("--remote-address", metavar="FQDN", help="VPN gateway FQDN") + g_vpn.add_argument("--match-domains", metavar="DOMAIN", nargs="+", help="Split DNS domains") + + g_meta = parser.add_argument_group("Profile metadata") + g_meta.add_argument("--identifier", metavar="ID", + help="Reverse-DNS profile identifier (derived from --remote-address if omitted)") + g_meta.add_argument("--output", required=True, metavar="FILE", help="Output .mobileconfig path") + g_meta.add_argument("--openssl", metavar="PATH", default=OPENSSL, + help=f"Path to the openssl binary (default: {OPENSSL})") + + g_names = parser.add_argument_group("Display name overrides") + g_names.add_argument("--profile-name", metavar="NAME", help="Profile display name") + g_names.add_argument("--ca-name", metavar="NAME", help="CA payload display name (default: cert CN)") + g_names.add_argument("--client-name", metavar="NAME", help="Client cert payload display name (default: cert CN)") + g_names.add_argument("--vpn-name", metavar="NAME", help="VPN connection payload display name (default: profile name)") + + args = parser.parse_args() + OPENSSL = args.openssl + + # Validate combinations + if args.client_cert and not args.client_key: + parser.error("--client-key is required when --client-cert is specified") + if args.client_key and not args.client_cert: + parser.error("--client-cert is required when --client-key is specified") + + vpn_args = [args.remote_address, args.match_domains] + if any(vpn_args) and not all(vpn_args): + parser.error("--remote-address and --match-domains must be specified together") + if args.remote_address and not args.client_cert: + parser.error("--client-cert/--client-key are required for VPN profiles") + + vpn = bool(args.remote_address) + has_client = bool(args.client_cert) + + # Identifier + if args.identifier: + identifier = args.identifier + elif args.remote_address: + identifier = ".".join(reversed(args.remote_address.split("."))) + else: + parser.error("--identifier is required when --remote-address is not specified") + + ca_path = Path(args.ca_cert) + ca_cn = cert_cn(ca_path) + + # Display names + profile_name = args.profile_name or ("VPN" if vpn else "Certificates") + ca_name = args.ca_name or ca_cn + vpn_name = args.vpn_name or profile_name + + payloads = [] + + # CA payload + payloads.append({ + "PayloadType": "com.apple.security.root", + "PayloadVersion": 1, + "PayloadIdentifier": f"{identifier}.cacert", + "PayloadUUID": str(uuid.uuid4()).upper(), + "PayloadDisplayName": ca_name, + "PayloadContent": cert_der(ca_path), + }) + + # Client certificate payload + if has_client: + client_path = Path(args.client_cert) + client_cn = cert_cn(client_path) + client_name = args.client_name or client_cn + p12_bytes, p12_password = build_p12(client_path, Path(args.client_key)) + uuid_cert = str(uuid.uuid4()).upper() + + payloads.append({ + "PayloadType": "com.apple.security.pkcs12", + "PayloadVersion": 1, + "PayloadIdentifier": f"{identifier}.usercert", + "PayloadUUID": uuid_cert, + "PayloadDisplayName": client_name, + "Password": p12_password, + "PayloadContent": p12_bytes, + }) + + # VPN payload + if vpn: + payloads.append({ + "PayloadType": "com.apple.vpn.managed", + "PayloadVersion": 1, + "PayloadIdentifier": f"{identifier}.vpn", + "PayloadUUID": str(uuid.uuid4()).upper(), + "PayloadDisplayName": vpn_name, + "VPNType": "IKEv2", + "IKEv2": { + "RemoteAddress": args.remote_address, + "RemoteIdentifier": args.remote_address, + "LocalIdentifier": client_cn, + "ServerCertificateIssuerCommonName": ca_cn, + "ServerCertificateCommonName": args.remote_address, + "AuthenticationMethod": "None", + "ExtendedAuthEnabled": 1, + "PayloadCertificateUUID": uuid_cert, + "SupplementalMatchDomains": args.match_domains, + "OnDemandEnabled": 0, + }, + }) + + profile = { + "PayloadDisplayName": profile_name, + "PayloadIdentifier": identifier, + "PayloadUUID": str(uuid.uuid4()).upper(), + "PayloadType": "Configuration", + "PayloadVersion": 1, + "PayloadContent": payloads, + } + + out = Path(args.output) + with out.open("wb") as f: + plistlib.dump(profile, f, fmt=plistlib.FMT_XML) + print(f"Written: {out}") + + +if __name__ == "__main__": + main() diff --git a/simple-ca.py b/simple-ca.py index c6efa64..0ee3d72 100755 --- a/simple-ca.py +++ b/simple-ca.py @@ -29,6 +29,8 @@ import re import subprocess import sys +OPENSSL = "openssl" + def _err(msg): print(f"ERROR: {msg}", file=sys.stderr) @@ -95,7 +97,7 @@ def make_ca(ca_dir, ca_name, days=3650, issuing_ca=None, aia_base_url=None): # one level of issuing CAs, but prevent a longer chain which is not # supported by this script. cmd = [ - "openssl", "req", + OPENSSL, "req", "-x509", "-newkey", "rsa:4096", "-keyout", root_ca_key_path, @@ -122,7 +124,7 @@ def make_ca(ca_dir, ca_name, days=3650, issuing_ca=None, aia_base_url=None): if not os.path.isfile(ca_cert_path) or not os.path.isfile(ca_key_path): print(f"Generating issuing CA certificate '{ca_name}' and key...") req_cmd = [ - "openssl", "req", + OPENSSL, "req", "-newkey", "rsa:4096", "-keyout", ca_key_path, "-noenc", @@ -136,7 +138,7 @@ def make_ca(ca_dir, ca_name, days=3650, issuing_ca=None, aia_base_url=None): f"authorityInfoAccess=caIssuers;URI:{aia_base_url}/ca_cert.crt", ] x509_cmd = [ - "openssl", "x509", + OPENSSL, "x509", "-req", "-CA", root_ca_cert_path, "-CAkey", root_ca_key_path, @@ -247,7 +249,7 @@ def make_cert(cert_dir, cert_subject_name, sans=None, ca_dir=None, if not os.path.isfile(cert_out) or not os.path.isfile(key_out): print("Generating server certificate and key...") req_cmd = [ - "openssl", "req", + OPENSSL, "req", "-newkey", "rsa:4096", "-keyout", key_out, "-noenc", @@ -260,7 +262,7 @@ def make_cert(cert_dir, cert_subject_name, sans=None, ca_dir=None, if aia_url: req_cmd += ["-addext", f"authorityInfoAccess=caIssuers;URI:{aia_url}"] x509_cmd = [ - "openssl", "x509", + OPENSSL, "x509", "-req", "-CA", ca_cert_path, "-CAkey", ca_key_path, @@ -343,7 +345,7 @@ def make_pfx(cert_path, ca_dir, issuing_ca=None, password=None): with os.fdopen(chain_fd, "wb") as f: f.write(chain_bytes) cmd = [ - "openssl", "pkcs12", + OPENSSL, "pkcs12", "-export", "-out", pfx_path, "-inkey", key_path, "-in", cert_path, @@ -372,6 +374,8 @@ def _build_parser(): p_ca.add_argument("--issuing-ca", help="Specify the issuing CA") p_ca.add_argument("--aia-base-url", help="Specify the AIA base URL") p_ca.add_argument("--ca-dir", help="Directory to store the CA files") + p_ca.add_argument("--openssl", default=OPENSSL, metavar="PATH", + help=f"Path to the openssl binary (default: {OPENSSL})") p_ca.add_argument("ca_name", help="Name of the CA") p_cert = sub.add_parser("make-cert", help="Create a server/client certificate.") @@ -379,6 +383,8 @@ def _build_parser(): p_cert.add_argument("--issuing-ca", help="Specify the issuing CA") p_cert.add_argument("--days", type=int, default=365, help="Validity period in days (default: 365)") p_cert.add_argument("--cert-dir", help="Directory to store the certificate files") + p_cert.add_argument("--openssl", default=OPENSSL, metavar="PATH", + help=f"Path to the openssl binary (default: {OPENSSL})") p_cert.add_argument("subject_name", help="Subject name for the certificate") p_cert.add_argument("sans", nargs="*", help="Subject Alternative Names (SANs) for the certificate") @@ -386,14 +392,19 @@ def _build_parser(): p_pfx.add_argument("--issuing-ca", help="Specify the issuing CA") p_pfx.add_argument("--ca-dir", help="Directory of the CA") p_pfx.add_argument("--password", help="Password for the PFX file") + p_pfx.add_argument("--openssl", default=OPENSSL, metavar="PATH", + help=f"Path to the openssl binary (default: {OPENSSL})") p_pfx.add_argument("path", help="Path to the certificate file") return parser def main(argv=None): + global OPENSSL + parser = _build_parser() args = parser.parse_args(argv) + OPENSSL = args.openssl ca_dir = args.ca_dir or os.environ.get("SIMPLE_CA_DIR") or os.getcwd() if args.command == "make-ca":