@daemux/store-automator 0.10.93 → 0.10.94

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,328 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Persistent cache for the Apple Distribution cert + per-bundle provisioning
4
+ profiles, stored under the workspace's ``creds/`` directory.
5
+
6
+ CI no longer regenerates a fresh cert and N profiles on every run. Apple
7
+ caps each team at 2 distribution certs and 7-day-rolling profile churn
8
+ triggers throttling, so re-using the same identity across runs is the
9
+ only sane long-term strategy. We keep:
10
+
11
+ creds/
12
+ cert.p12 PKCS12 with cert + private key
13
+ cert.meta.json {cert_id, not_after, p12_password,
14
+ created_at}
15
+ profiles.manifest.json {cert_id, profiles:[
16
+ {bundle_id, name, uuid, filename,
17
+ expiration}]}
18
+ profiles/<uuid>.mobileprovision one per signable bundle
19
+
20
+ Reuse rules (encoded in :func:`load_cached_cert` and
21
+ :func:`find_reusable_profile`):
22
+
23
+ * Cert is reusable iff cert.p12 exists, decrypts with the stored
24
+ password, NotAfter is more than 30 days away, AND
25
+ GET /certificates/{cert_id} returns 200 (Apple hasn't revoked it).
26
+ * Profile is reusable iff the cert is reusable AND the manifest entry
27
+ has matching cert_id AND expiration is more than 30 days away AND
28
+ the .mobileprovision file is on disk.
29
+
30
+ Anything outside reuse triggers regen + cache replacement (atomic).
31
+
32
+ `p12_password` stays ``"ci"`` deliberately. Encryption-at-rest of the
33
+ PKCS12 is decorative when the ``.p8`` ASC API key sits next to it in
34
+ plaintext — anyone who can read one can read the other. The repo MUST
35
+ stay private; the cert lives there for warm-cache reasons, not secrecy.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import json
41
+ import os
42
+ from dataclasses import dataclass
43
+ from datetime import datetime, timedelta, timezone
44
+ from pathlib import Path
45
+ from typing import Any
46
+
47
+ from asc_common import request
48
+ from cryptography import x509
49
+ from cryptography.hazmat.primitives.serialization import pkcs12
50
+
51
+
52
+ # Auto-renew when the cert (or any profile) is within this many days of
53
+ # expiring. Apple distribution certs are valid for 1 year and profiles
54
+ # for 1 year, so 30 days is plenty of headroom for CI to roll.
55
+ RENEW_THRESHOLD_DAYS = 30
56
+
57
+ # Surface a non-fatal warning when the cert is within this many days of
58
+ # expiry but not yet inside the auto-renew window. Gives operators
59
+ # visibility before the renew actually fires.
60
+ WARN_THRESHOLD_DAYS = 60
61
+
62
+ # DESIGN DECISION (intentional, not a bug):
63
+ # P12 password is a hardcoded literal because encryption-at-rest is
64
+ # decorative in this storage model: the .p12 lives next to the .p8 ASC
65
+ # API key in the same private repo. Both are private keys; protecting one
66
+ # with a sourced password while the other sits in plaintext alongside
67
+ # adds bug surface (password drift between meta.json and actual encryption)
68
+ # without raising the security floor. If this storage model changes (e.g.
69
+ # .p8 moves to GH Secrets), revisit and source this from a secret too.
70
+ P12_PASSWORD = "ci"
71
+
72
+ # File-name layout under creds/. Centralised so callers never compute
73
+ # their own paths and the layout stays a single fact in one place.
74
+ _CERT_P12 = "cert.p12"
75
+ _CERT_META = "cert.meta.json"
76
+ _MANIFEST = "profiles.manifest.json"
77
+ _PROFILES_SUBDIR = "profiles"
78
+
79
+
80
+ @dataclass
81
+ class CachedCert:
82
+ cert_id: str
83
+ not_after: datetime
84
+ p12_bytes: bytes
85
+ password: str
86
+
87
+
88
+ @dataclass
89
+ class ProfileEntry:
90
+ bundle_id: str
91
+ name: str
92
+ uuid: str
93
+ filename: str
94
+ expiration: datetime
95
+
96
+
97
+ # --------------------------------------------------------------------------- #
98
+ # Internal helpers (atomic IO + datetime parsing) #
99
+ # --------------------------------------------------------------------------- #
100
+
101
+ def _atomic_write(path: Path, data: bytes) -> None:
102
+ path.parent.mkdir(parents=True, exist_ok=True)
103
+ tmp = path.with_suffix(path.suffix + ".tmp")
104
+ tmp.write_bytes(data)
105
+ os.replace(tmp, path)
106
+
107
+
108
+ def _parse_iso(value: str) -> datetime:
109
+ """Parse an ISO-8601 datetime; tolerate trailing 'Z' for UTC."""
110
+ if value.endswith("Z"):
111
+ value = value[:-1] + "+00:00"
112
+ parsed = datetime.fromisoformat(value)
113
+ if parsed.tzinfo is None:
114
+ parsed = parsed.replace(tzinfo=timezone.utc)
115
+ return parsed
116
+
117
+
118
+ def _now_utc() -> datetime:
119
+ return datetime.now(timezone.utc)
120
+
121
+
122
+ # --------------------------------------------------------------------------- #
123
+ # Path computations (pure) #
124
+ # --------------------------------------------------------------------------- #
125
+
126
+ def profile_path(creds_dir: Path, uuid: str) -> Path:
127
+ """Return the on-disk path for a cached profile by UUID (pure)."""
128
+ return creds_dir / _PROFILES_SUBDIR / f"{uuid}.mobileprovision"
129
+
130
+
131
+ # --------------------------------------------------------------------------- #
132
+ # Cert cache #
133
+ # --------------------------------------------------------------------------- #
134
+
135
+ def load_cached_cert(creds_dir: Path) -> CachedCert | None:
136
+ """Return the cached cert iff p12 + meta exist and decrypt cleanly.
137
+
138
+ Returns ``None`` (and logs a ``::warning::``) on any of:
139
+ * missing p12 or meta file
140
+ * meta JSON corrupt / missing required keys
141
+ * p12 fails to decrypt with the stored password
142
+ * cert NotAfter is within RENEW_THRESHOLD_DAYS
143
+
144
+ A returned :class:`CachedCert` is NOT yet validated against Apple —
145
+ the caller must still confirm the cert id is alive via
146
+ :func:`verify_cert_alive`.
147
+ """
148
+ p12 = creds_dir / _CERT_P12
149
+ meta = creds_dir / _CERT_META
150
+ if not p12.exists() or not meta.exists():
151
+ return None
152
+
153
+ try:
154
+ meta_raw = json.loads(meta.read_text())
155
+ cert_id = meta_raw["cert_id"]
156
+ not_after = _parse_iso(meta_raw["not_after"])
157
+ password = meta_raw.get("p12_password") or P12_PASSWORD
158
+ except (OSError, ValueError, KeyError) as exc:
159
+ print(f"::warning::cert.meta.json unreadable ({exc}); regenerating cert")
160
+ return None
161
+
162
+ p12_bytes = p12.read_bytes()
163
+ try:
164
+ pkcs12.load_key_and_certificates(p12_bytes, password.encode())
165
+ except (ValueError, TypeError) as exc:
166
+ print(f"::warning::cert.p12 decrypt failed ({exc}); regenerating cert")
167
+ return None
168
+
169
+ deadline = _now_utc() + timedelta(days=RENEW_THRESHOLD_DAYS)
170
+ if not_after <= deadline:
171
+ print(
172
+ f"::warning::Cached cert {cert_id} expires {not_after.isoformat()} "
173
+ f"(within {RENEW_THRESHOLD_DAYS}d); regenerating"
174
+ )
175
+ return None
176
+
177
+ return CachedCert(
178
+ cert_id=cert_id,
179
+ not_after=not_after,
180
+ p12_bytes=p12_bytes,
181
+ password=password,
182
+ )
183
+
184
+
185
+ def verify_cert_alive(token: str, cert_id: str) -> bool:
186
+ """Return True iff GET /certificates/{cert_id} returns 200.
187
+
188
+ A 404 means Apple revoked it (manually or via the cap-rotation done
189
+ by ``oldest_distribution_cert_id``); any other non-200 is treated
190
+ conservatively as not-alive so we regenerate.
191
+ """
192
+ resp = request(
193
+ "GET",
194
+ f"/certificates/{cert_id}",
195
+ token,
196
+ allow_status={404},
197
+ )
198
+ return resp.status_code == 200
199
+
200
+
201
+ def write_cert_bundle(
202
+ creds_dir: Path,
203
+ cert_id: str,
204
+ p12_bytes: bytes,
205
+ password: str,
206
+ not_after: datetime,
207
+ ) -> None:
208
+ """Persist the cert bundle atomically (p12 + meta)."""
209
+ creds_dir.mkdir(parents=True, exist_ok=True)
210
+ _atomic_write(creds_dir / _CERT_P12, p12_bytes)
211
+ meta = {
212
+ "cert_id": cert_id,
213
+ "not_after": not_after.astimezone(timezone.utc).isoformat(),
214
+ # Password persisted alongside the cert it unlocks — see DESIGN
215
+ # DECISION block at P12_PASSWORD definition above for the
216
+ # rationale (encryption-at-rest is decorative when the .p8 lives
217
+ # in plaintext alongside in the same private repo).
218
+ "p12_password": password,
219
+ "created_at": _now_utc().isoformat(),
220
+ }
221
+ _atomic_write(creds_dir / _CERT_META, json.dumps(meta, indent=2).encode())
222
+
223
+
224
+ # --------------------------------------------------------------------------- #
225
+ # Profile manifest #
226
+ # --------------------------------------------------------------------------- #
227
+
228
+ def load_profile_manifest(creds_dir: Path) -> dict:
229
+ """Return the parsed manifest, or an empty skeleton if missing/corrupt."""
230
+ path = creds_dir / _MANIFEST
231
+ if not path.exists():
232
+ return {"cert_id": None, "profiles": []}
233
+ try:
234
+ data = json.loads(path.read_text())
235
+ except (OSError, ValueError) as exc:
236
+ print(f"::warning::profiles.manifest.json unreadable ({exc}); resetting")
237
+ return {"cert_id": None, "profiles": []}
238
+ data.setdefault("cert_id", None)
239
+ data.setdefault("profiles", [])
240
+ return data
241
+
242
+
243
+ def write_profile_manifest(creds_dir: Path, manifest: dict) -> None:
244
+ """Persist the manifest atomically with profiles sorted by bundle_id."""
245
+ sorted_profiles = sorted(
246
+ manifest.get("profiles", []),
247
+ key=lambda entry: entry.get("bundle_id", ""),
248
+ )
249
+ payload = {
250
+ "cert_id": manifest.get("cert_id"),
251
+ "profiles": sorted_profiles,
252
+ }
253
+ _atomic_write(
254
+ creds_dir / _MANIFEST, json.dumps(payload, indent=2).encode()
255
+ )
256
+
257
+
258
+ def find_reusable_profile(
259
+ manifest: dict,
260
+ bundle_id: str,
261
+ cert_id: str,
262
+ creds_dir: Path,
263
+ ) -> ProfileEntry | None:
264
+ """Return a reusable :class:`ProfileEntry` for ``bundle_id`` or None.
265
+
266
+ Reusable requires: manifest cert_id matches current cert_id, an
267
+ entry exists for the bundle, expiration is past the renew
268
+ threshold, and the .mobileprovision file is on disk.
269
+ """
270
+ if manifest.get("cert_id") != cert_id:
271
+ return None
272
+ deadline = _now_utc() + timedelta(days=RENEW_THRESHOLD_DAYS)
273
+ for raw in manifest.get("profiles", []):
274
+ if raw.get("bundle_id") != bundle_id:
275
+ continue
276
+ try:
277
+ expiration = _parse_iso(raw["expiration"])
278
+ uuid = raw["uuid"]
279
+ except (KeyError, ValueError):
280
+ return None
281
+ if expiration <= deadline:
282
+ return None
283
+ if not profile_path(creds_dir, uuid).exists():
284
+ return None
285
+ return ProfileEntry(
286
+ bundle_id=bundle_id,
287
+ name=raw.get("name", ""),
288
+ uuid=uuid,
289
+ filename=raw.get("filename", f"{uuid}.mobileprovision"),
290
+ expiration=expiration,
291
+ )
292
+ return None
293
+
294
+
295
+ # --------------------------------------------------------------------------- #
296
+ # Cache invalidation + cross-module utilities #
297
+ # --------------------------------------------------------------------------- #
298
+
299
+ def invalidate_cache(creds_dir: Path) -> None:
300
+ """Remove all cache artifacts under ``creds_dir`` (best-effort)."""
301
+ for name in (_CERT_P12, _CERT_META, _MANIFEST):
302
+ (creds_dir / name).unlink(missing_ok=True)
303
+ pdir = creds_dir / _PROFILES_SUBDIR
304
+ if pdir.is_dir():
305
+ for entry in pdir.iterdir():
306
+ if entry.suffix == ".mobileprovision":
307
+ entry.unlink(missing_ok=True)
308
+ print(f"Invalidated signing cache under {creds_dir}")
309
+
310
+
311
+ def write_cached_profile(creds_dir: Path, uuid: str, profile_der: bytes) -> None:
312
+ """Persist a fresh .mobileprovision atomically under ``profiles/``."""
313
+ _atomic_write(profile_path(creds_dir, uuid), profile_der)
314
+
315
+
316
+ def cert_not_after_from_der(cert_der: bytes) -> datetime:
317
+ """Extract NotAfter from a DER-encoded certificate, normalised to UTC.
318
+
319
+ Prefers ``not_valid_after_utc`` (cryptography >= 42); falls back to
320
+ the deprecated naive-UTC ``not_valid_after`` for older runtimes. Also
321
+ used by the prepare_signing entrypoint to decide T-60d warnings on
322
+ cached certs without re-loading the PKCS12.
323
+ """
324
+ cert = x509.load_der_x509_certificate(cert_der)
325
+ try:
326
+ return cert.not_valid_after_utc
327
+ except AttributeError:
328
+ return cert.not_valid_after.replace(tzinfo=timezone.utc)
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Throwaway-keychain provisioning for codesign / xcodebuild on a CI runner.
4
+
5
+ Creates a temporary keychain in ``$RUNNER_TEMP``, imports a PKCS12 cert
6
+ into it, and prepends it to the user's keychain search list so
7
+ ``codesign`` and ``xcodebuild`` find the identity at archive time. The
8
+ keychain is purposely short-lived (default 6h auto-lock) and disposable
9
+ — each CI run re-creates it from scratch.
10
+
11
+ The keychain password (``"ci"``) is intentionally hardcoded: the
12
+ keychain never leaves the runner (it lives in ``$RUNNER_TEMP`` which
13
+ GitHub deletes when the job ends), so encrypting it with a sourced
14
+ password adds bug surface (drift between import + unlock calls) without
15
+ raising the security floor. This is distinct from the persisted P12
16
+ password documented in ``creds_store.P12_PASSWORD``.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import os
22
+ import subprocess
23
+ from pathlib import Path
24
+
25
+
26
+ _KEYCHAIN_FILENAME = "ci.keychain-db"
27
+ _KEYCHAIN_PASS = "ci"
28
+
29
+
30
+ def _create_keychain(keychain_path: str, keychain_pass: str) -> None:
31
+ subprocess.run(
32
+ ["security", "delete-keychain", keychain_path],
33
+ check=False,
34
+ capture_output=True,
35
+ )
36
+ subprocess.check_call(
37
+ ["security", "create-keychain", "-p", keychain_pass, keychain_path]
38
+ )
39
+ subprocess.check_call(
40
+ ["security", "set-keychain-settings", "-lut", "21600", keychain_path]
41
+ )
42
+ subprocess.check_call(
43
+ ["security", "unlock-keychain", "-p", keychain_pass, keychain_path]
44
+ )
45
+
46
+
47
+ def _import_p12(
48
+ keychain_path: str, keychain_pass: str, p12_path: Path, p12_pass: str
49
+ ) -> None:
50
+ subprocess.check_call(
51
+ [
52
+ "security", "import", str(p12_path),
53
+ "-P", p12_pass,
54
+ "-A",
55
+ "-t", "cert",
56
+ "-f", "pkcs12",
57
+ "-k", keychain_path,
58
+ ]
59
+ )
60
+ subprocess.check_call(
61
+ [
62
+ "security", "set-key-partition-list",
63
+ "-S", "apple-tool:,apple:,codesign:",
64
+ "-s",
65
+ "-k", keychain_pass,
66
+ keychain_path,
67
+ ]
68
+ )
69
+
70
+
71
+ def _prepend_to_user_search_list(keychain_path: str) -> None:
72
+ existing = subprocess.check_output(
73
+ ["security", "list-keychains", "-d", "user"]
74
+ ).decode()
75
+ existing_list = [
76
+ line.strip().strip('"')
77
+ for line in existing.splitlines()
78
+ if line.strip()
79
+ ]
80
+ new_list = [keychain_path] + [
81
+ k for k in existing_list if k != keychain_path
82
+ ]
83
+ subprocess.check_call(
84
+ ["security", "list-keychains", "-d", "user", "-s", *new_list]
85
+ )
86
+ subprocess.check_call(
87
+ ["security", "default-keychain", "-s", keychain_path]
88
+ )
89
+
90
+
91
+ def setup_keychain(p12_path: Path, p12_pass: str, runner_temp: str) -> str:
92
+ """Create + populate the throwaway CI keychain; return its path.
93
+
94
+ ``runner_temp`` is the GitHub Actions temp directory (``$RUNNER_TEMP``);
95
+ the keychain lives there so it auto-cleans when the job finishes.
96
+ Returns the keychain path so callers can reference it for diagnostics.
97
+ """
98
+ keychain_path = os.path.join(runner_temp, _KEYCHAIN_FILENAME)
99
+ _create_keychain(keychain_path, _KEYCHAIN_PASS)
100
+ _import_p12(keychain_path, _KEYCHAIN_PASS, p12_path, p12_pass)
101
+ _prepend_to_user_search_list(keychain_path)
102
+ print(f"Keychain ready: {keychain_path}")
103
+ return keychain_path