feat: Add generate-mobileconfig.py script for creating Apple mobileconfig profiles
This commit is contained in:
@@ -13,3 +13,4 @@ __pycache__/
|
||||
# Do not store AI Agent instructions
|
||||
AGENTS.md
|
||||
CLAUDE.md
|
||||
.claude/
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
+17
-6
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user