gowalk-cicd 1.0.39 → 1.0.40
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.
- package/README.md +15 -8
- package/action/.daemux-version +1 -1
- package/action/action.yml +1 -0
- package/action/scripts/creds_store.py +78 -111
- package/action/scripts/prepare_signing.py +30 -22
- package/action/scripts/profile_store.py +87 -0
- package/action/scripts/test_registry_managed_signing.py +115 -0
- package/android-action/.daemux-version +1 -1
- package/backend-action/.daemux-version +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -330,14 +330,21 @@ its cached cert already revoked by a sibling, mints a new one, and revokes
|
|
|
330
330
|
another. The symptom is a "Your Certificate Has Been Revoked" email on nearly
|
|
331
331
|
every deploy.
|
|
332
332
|
|
|
333
|
-
Fix: apps in the same Apple team should **share one cert**.
|
|
334
|
-
`creds/cert.p12` + `creds/cert.meta.json` into every repo of
|
|
335
|
-
Distribution cert is team-scoped, not app-scoped, so one
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
333
|
+
Fix: apps in the same Apple team should **share one cert**. A fleet registry can
|
|
334
|
+
provision the same `creds/cert.p12` + `creds/cert.meta.json` into every repo of
|
|
335
|
+
that team (an Apple Distribution cert is team-scoped, not app-scoped, so one
|
|
336
|
+
cert signs them all). Registry provisioning must also write
|
|
337
|
+
`creds/cert.registry.json` with `managed_by: app-robot`, `state: ready`, and
|
|
338
|
+
SHA-256 commit digests for both `cert.p12` and `cert.meta.json`. It must include
|
|
339
|
+
the same `managed_by` field plus the DER certificate digest in
|
|
340
|
+
`cert.meta.json`.
|
|
341
|
+
|
|
342
|
+
The marker is a fail-closed ownership boundary: the action may reuse the
|
|
343
|
+
identity and create per-app provisioning profiles, but it never creates,
|
|
344
|
+
replaces, or revokes a registry-managed distribution certificate. Missing,
|
|
345
|
+
partially written, corrupt, expired, Apple-revoked, or resource-ID/P12-mismatched
|
|
346
|
+
managed material aborts with a reconciliation error. Legacy repos without the
|
|
347
|
+
marker keep the historical cache lifecycle.
|
|
341
348
|
|
|
342
349
|
## How it works
|
|
343
350
|
|
package/action/.daemux-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.40
|
package/action/action.yml
CHANGED
|
@@ -460,6 +460,7 @@ runs:
|
|
|
460
460
|
# the source of truth for "is there anything to commit".
|
|
461
461
|
git add -f -- creds/cert.p12 2>/dev/null || true
|
|
462
462
|
git add -f -- creds/cert.meta.json 2>/dev/null || true
|
|
463
|
+
git add -f -- creds/cert.registry.json 2>/dev/null || true
|
|
463
464
|
git add -f -- creds/profiles.manifest.json 2>/dev/null || true
|
|
464
465
|
# The whole profiles/ directory is action-managed (one
|
|
465
466
|
# .mobileprovision per signable bundle, GC'd when bundles are
|
|
@@ -12,6 +12,7 @@ only sane long-term strategy. We keep:
|
|
|
12
12
|
cert.p12 PKCS12 with cert + private key
|
|
13
13
|
cert.meta.json {cert_id, not_after, p12_password,
|
|
14
14
|
created_at}
|
|
15
|
+
cert.registry.json optional registry ownership marker
|
|
15
16
|
profiles.manifest.json {cert_id, profiles:[
|
|
16
17
|
{bundle_id, name, uuid, filename,
|
|
17
18
|
expiration}]}
|
|
@@ -37,16 +38,25 @@ stay private; the cert lives there for warm-cache reasons, not secrecy.
|
|
|
37
38
|
|
|
38
39
|
from __future__ import annotations
|
|
39
40
|
|
|
41
|
+
import base64
|
|
42
|
+
import hashlib
|
|
40
43
|
import json
|
|
41
44
|
import os
|
|
42
45
|
from dataclasses import dataclass
|
|
43
46
|
from datetime import datetime, timedelta, timezone
|
|
44
47
|
from pathlib import Path
|
|
45
|
-
from typing import Any
|
|
46
48
|
|
|
47
49
|
from asc_common import request
|
|
48
50
|
from cryptography import x509
|
|
49
|
-
from cryptography.hazmat.primitives.serialization import pkcs12
|
|
51
|
+
from cryptography.hazmat.primitives.serialization import Encoding, pkcs12
|
|
52
|
+
from profile_store import (
|
|
53
|
+
ProfileEntry,
|
|
54
|
+
find_reusable_profile,
|
|
55
|
+
load_profile_manifest,
|
|
56
|
+
profile_path,
|
|
57
|
+
write_cached_profile,
|
|
58
|
+
write_profile_manifest,
|
|
59
|
+
)
|
|
50
60
|
|
|
51
61
|
|
|
52
62
|
# Auto-renew when the cert (or any profile) is within this many days of
|
|
@@ -73,8 +83,10 @@ P12_PASSWORD = "ci"
|
|
|
73
83
|
# their own paths and the layout stays a single fact in one place.
|
|
74
84
|
_CERT_P12 = "cert.p12"
|
|
75
85
|
_CERT_META = "cert.meta.json"
|
|
86
|
+
_CERT_REGISTRY = "cert.registry.json"
|
|
76
87
|
_MANIFEST = "profiles.manifest.json"
|
|
77
88
|
_PROFILES_SUBDIR = "profiles"
|
|
89
|
+
REGISTRY_MANAGER = "app-robot"
|
|
78
90
|
|
|
79
91
|
|
|
80
92
|
@dataclass
|
|
@@ -83,15 +95,7 @@ class CachedCert:
|
|
|
83
95
|
not_after: datetime
|
|
84
96
|
p12_bytes: bytes
|
|
85
97
|
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
|
|
98
|
+
certificate_sha256: str
|
|
95
99
|
|
|
96
100
|
|
|
97
101
|
# --------------------------------------------------------------------------- #
|
|
@@ -119,13 +123,43 @@ def _now_utc() -> datetime:
|
|
|
119
123
|
return datetime.now(timezone.utc)
|
|
120
124
|
|
|
121
125
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
# --------------------------------------------------------------------------- #
|
|
126
|
+
def registry_managed(creds_dir: Path) -> bool:
|
|
127
|
+
"""Return whether the identity is owned by the app-robot registry.
|
|
125
128
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
+
The dedicated marker makes ownership detectable even when the P12 or
|
|
130
|
+
metadata is missing/corrupt. Ownership is intentionally marker-only: a
|
|
131
|
+
rollback removes this panel-owned file while keeping the working P12/meta
|
|
132
|
+
cache intact. Unknown managers fail closed instead of falling into legacy
|
|
133
|
+
rotation.
|
|
134
|
+
"""
|
|
135
|
+
marker = creds_dir / _CERT_REGISTRY
|
|
136
|
+
return _registry_payload_managed(marker) if marker.exists() else False
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _registry_payload_managed(path: Path) -> bool:
|
|
140
|
+
try:
|
|
141
|
+
payload = json.loads(path.read_text())
|
|
142
|
+
except (OSError, ValueError) as exc:
|
|
143
|
+
raise SystemExit(f"registry marker is unreadable: {exc}") from exc
|
|
144
|
+
manager = payload.get("managed_by")
|
|
145
|
+
if not manager:
|
|
146
|
+
raise SystemExit("registry marker is missing managed_by")
|
|
147
|
+
if manager != REGISTRY_MANAGER:
|
|
148
|
+
raise SystemExit(f"unsupported signing registry manager: {manager}")
|
|
149
|
+
if payload.get("state") != "ready":
|
|
150
|
+
raise SystemExit("registry-managed signing bundle is not committed and ready")
|
|
151
|
+
files = (("p12_sha256", _CERT_P12), ("metadata_sha256", _CERT_META))
|
|
152
|
+
for field, filename in files:
|
|
153
|
+
expected = str(payload.get(field) or "").lower()
|
|
154
|
+
if len(expected) != 64 or any(char not in "0123456789abcdef" for char in expected):
|
|
155
|
+
raise SystemExit(f"registry marker has invalid {field}")
|
|
156
|
+
try:
|
|
157
|
+
actual = hashlib.sha256((path.parent / filename).read_bytes()).hexdigest()
|
|
158
|
+
except OSError as exc:
|
|
159
|
+
raise SystemExit(f"registry signing bundle is incomplete: {filename}") from exc
|
|
160
|
+
if actual != expected:
|
|
161
|
+
raise SystemExit(f"registry signing bundle digest mismatch: {filename}")
|
|
162
|
+
return True
|
|
129
163
|
|
|
130
164
|
|
|
131
165
|
# --------------------------------------------------------------------------- #
|
|
@@ -133,18 +167,7 @@ def profile_path(creds_dir: Path, uuid: str) -> Path:
|
|
|
133
167
|
# --------------------------------------------------------------------------- #
|
|
134
168
|
|
|
135
169
|
def load_cached_cert(creds_dir: Path) -> CachedCert | None:
|
|
136
|
-
"""
|
|
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
|
-
"""
|
|
170
|
+
"""Load a locally valid cache; the caller still verifies it with Apple."""
|
|
148
171
|
p12 = creds_dir / _CERT_P12
|
|
149
172
|
meta = creds_dir / _CERT_META
|
|
150
173
|
if not p12.exists() or not meta.exists():
|
|
@@ -156,21 +179,30 @@ def load_cached_cert(creds_dir: Path) -> CachedCert | None:
|
|
|
156
179
|
not_after = _parse_iso(meta_raw["not_after"])
|
|
157
180
|
password = meta_raw.get("p12_password") or P12_PASSWORD
|
|
158
181
|
except (OSError, ValueError, KeyError) as exc:
|
|
159
|
-
print(f"::warning::cert.meta.json unreadable ({exc});
|
|
182
|
+
print(f"::warning::cert.meta.json unreadable ({exc}); identity cannot be reused")
|
|
160
183
|
return None
|
|
161
184
|
|
|
162
185
|
p12_bytes = p12.read_bytes()
|
|
163
186
|
try:
|
|
164
|
-
pkcs12.load_key_and_certificates(p12_bytes, password.encode())
|
|
187
|
+
_, certificate, _ = pkcs12.load_key_and_certificates(p12_bytes, password.encode())
|
|
165
188
|
except (ValueError, TypeError) as exc:
|
|
166
|
-
print(f"::warning::cert.p12 decrypt failed ({exc});
|
|
189
|
+
print(f"::warning::cert.p12 decrypt failed ({exc}); identity cannot be reused")
|
|
190
|
+
return None
|
|
191
|
+
if certificate is None:
|
|
192
|
+
print("::warning::cert.p12 has no certificate; identity cannot be reused")
|
|
193
|
+
return None
|
|
194
|
+
certificate_sha256 = hashlib.sha256(
|
|
195
|
+
certificate.public_bytes(Encoding.DER)).hexdigest()
|
|
196
|
+
declared_digest = str(meta_raw.get("certificate_sha256") or "").lower()
|
|
197
|
+
if declared_digest and declared_digest != certificate_sha256:
|
|
198
|
+
print("::warning::cert.meta.json certificate digest does not match cert.p12")
|
|
167
199
|
return None
|
|
168
200
|
|
|
169
201
|
deadline = _now_utc() + timedelta(days=RENEW_THRESHOLD_DAYS)
|
|
170
202
|
if not_after <= deadline:
|
|
171
203
|
print(
|
|
172
204
|
f"::warning::Cached cert {cert_id} expires {not_after.isoformat()} "
|
|
173
|
-
f"(within {RENEW_THRESHOLD_DAYS}d);
|
|
205
|
+
f"(within {RENEW_THRESHOLD_DAYS}d); identity requires replacement"
|
|
174
206
|
)
|
|
175
207
|
return None
|
|
176
208
|
|
|
@@ -179,10 +211,12 @@ def load_cached_cert(creds_dir: Path) -> CachedCert | None:
|
|
|
179
211
|
not_after=not_after,
|
|
180
212
|
p12_bytes=p12_bytes,
|
|
181
213
|
password=password,
|
|
214
|
+
certificate_sha256=certificate_sha256,
|
|
182
215
|
)
|
|
183
216
|
|
|
184
217
|
|
|
185
|
-
def verify_cert_alive(token: str, cert_id: str
|
|
218
|
+
def verify_cert_alive(token: str, cert_id: str,
|
|
219
|
+
expected_certificate_sha256: str | None = None) -> bool:
|
|
186
220
|
"""Return True iff GET /certificates/{cert_id} returns 200.
|
|
187
221
|
|
|
188
222
|
A 404 means Apple revoked it (manually or via the cap-rotation done
|
|
@@ -195,7 +229,16 @@ def verify_cert_alive(token: str, cert_id: str) -> bool:
|
|
|
195
229
|
token,
|
|
196
230
|
allow_status={404},
|
|
197
231
|
)
|
|
198
|
-
|
|
232
|
+
if resp.status_code != 200:
|
|
233
|
+
return False
|
|
234
|
+
if expected_certificate_sha256 is None:
|
|
235
|
+
return True
|
|
236
|
+
try:
|
|
237
|
+
encoded = resp.json()["data"]["attributes"]["certificateContent"]
|
|
238
|
+
certificate_der = base64.b64decode(encoded, validate=True)
|
|
239
|
+
except (KeyError, TypeError, ValueError):
|
|
240
|
+
return False
|
|
241
|
+
return hashlib.sha256(certificate_der).hexdigest() == expected_certificate_sha256
|
|
199
242
|
|
|
200
243
|
|
|
201
244
|
def write_cert_bundle(
|
|
@@ -221,77 +264,6 @@ def write_cert_bundle(
|
|
|
221
264
|
_atomic_write(creds_dir / _CERT_META, json.dumps(meta, indent=2).encode())
|
|
222
265
|
|
|
223
266
|
|
|
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
267
|
# --------------------------------------------------------------------------- #
|
|
296
268
|
# Cache invalidation + cross-module utilities #
|
|
297
269
|
# --------------------------------------------------------------------------- #
|
|
@@ -308,11 +280,6 @@ def invalidate_cache(creds_dir: Path) -> None:
|
|
|
308
280
|
print(f"Invalidated signing cache under {creds_dir}")
|
|
309
281
|
|
|
310
282
|
|
|
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
283
|
def cert_not_after_from_der(cert_der: bytes) -> datetime:
|
|
317
284
|
"""Extract NotAfter from a DER-encoded certificate, normalised to UTC.
|
|
318
285
|
|
|
@@ -9,9 +9,9 @@ This script orchestrates the load-or-regen flow:
|
|
|
9
9
|
|
|
10
10
|
1. Try to reuse the cached cert (decrypts + NotAfter > 30d +
|
|
11
11
|
``GET /certificates/{cert_id}`` = 200).
|
|
12
|
-
2. On miss, invalidate the cache and create a fresh cert via
|
|
13
|
-
``cert_factory
|
|
14
|
-
|
|
12
|
+
2. On a legacy cache miss, invalidate the cache and create a fresh cert via
|
|
13
|
+
``cert_factory``. A registry-managed identity instead fails closed: this
|
|
14
|
+
action never creates, replaces, or revokes centrally owned certificates.
|
|
15
15
|
3. Hand the cert id + cache_hit flag to ``provision_all_bundles``,
|
|
16
16
|
which decides per-bundle whether to reuse a cached profile or
|
|
17
17
|
create a new one and updates the manifest.
|
|
@@ -59,35 +59,43 @@ def optional_env(name: str) -> str:
|
|
|
59
59
|
return os.environ.get(name, "")
|
|
60
60
|
|
|
61
61
|
|
|
62
|
+
def _warn_if_renewal_near(cached: creds_store.CachedCert) -> None:
|
|
63
|
+
now = datetime.now(timezone.utc)
|
|
64
|
+
renew_at = now + timedelta(days=creds_store.RENEW_THRESHOLD_DAYS)
|
|
65
|
+
warn_at = now + timedelta(days=creds_store.WARN_THRESHOLD_DAYS)
|
|
66
|
+
if renew_at < cached.not_after <= warn_at:
|
|
67
|
+
print(
|
|
68
|
+
f"::warning::Cert {cached.cert_id} expires in "
|
|
69
|
+
f"{(cached.not_after - now).days}d; auto-renew fires at "
|
|
70
|
+
f"{creds_store.RENEW_THRESHOLD_DAYS}d remaining."
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
62
74
|
def _load_or_create_cert(
|
|
63
75
|
token: str, creds_dir: Path
|
|
64
76
|
) -> tuple[str, bytes, str, bool]:
|
|
65
|
-
"""
|
|
66
|
-
|
|
67
|
-
Cache hit when the on-disk cert decrypts, NotAfter > 30d away, and
|
|
68
|
-
Apple confirms the cert id is still alive. On miss the cache is
|
|
69
|
-
invalidated, a new cert is created (handling Apple's per-team cap),
|
|
70
|
-
and the cache is repopulated atomically.
|
|
71
|
-
"""
|
|
77
|
+
"""Load a usable identity or create one only for an unmanaged legacy repo."""
|
|
78
|
+
managed = creds_store.registry_managed(creds_dir)
|
|
72
79
|
cached = creds_store.load_cached_cert(creds_dir)
|
|
73
|
-
if cached and
|
|
80
|
+
expected_digest = cached.certificate_sha256 if cached and managed else None
|
|
81
|
+
if cached and creds_store.verify_cert_alive(token, cached.cert_id, expected_digest):
|
|
74
82
|
print(
|
|
75
83
|
f"Reusing cached distribution cert {cached.cert_id} "
|
|
76
84
|
f"(NotAfter {cached.not_after.isoformat()})"
|
|
77
85
|
)
|
|
78
|
-
|
|
79
|
-
# but auto-renew has not yet fired.
|
|
80
|
-
now = datetime.now(timezone.utc)
|
|
81
|
-
renew_at = now + timedelta(days=creds_store.RENEW_THRESHOLD_DAYS)
|
|
82
|
-
warn_at = now + timedelta(days=creds_store.WARN_THRESHOLD_DAYS)
|
|
83
|
-
if renew_at < cached.not_after <= warn_at:
|
|
84
|
-
print(
|
|
85
|
-
f"::warning::Cert {cached.cert_id} expires in "
|
|
86
|
-
f"{(cached.not_after - now).days}d; auto-renew fires at "
|
|
87
|
-
f"{creds_store.RENEW_THRESHOLD_DAYS}d remaining."
|
|
88
|
-
)
|
|
86
|
+
_warn_if_renewal_near(cached)
|
|
89
87
|
return cached.cert_id, cached.p12_bytes, cached.password, True
|
|
90
88
|
|
|
89
|
+
if managed:
|
|
90
|
+
detail = "missing, corrupt, or within the renewal window"
|
|
91
|
+
if cached:
|
|
92
|
+
detail = f"certificate {cached.cert_id} is not active on Apple or does not match cert.p12"
|
|
93
|
+
raise SystemExit(
|
|
94
|
+
"registry-managed distribution identity is unusable "
|
|
95
|
+
f"({detail}); refusing certificate creation or revocation. "
|
|
96
|
+
"Reconcile and replace the account identity in app-robot."
|
|
97
|
+
)
|
|
98
|
+
|
|
91
99
|
if cached:
|
|
92
100
|
print(
|
|
93
101
|
f"Cached cert {cached.cert_id} no longer alive on Apple; "
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Persistent provisioning-profile cache under an app's creds directory."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from datetime import datetime, timedelta, timezone
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
RENEW_THRESHOLD_DAYS = 30
|
|
12
|
+
_MANIFEST = "profiles.manifest.json"
|
|
13
|
+
_PROFILES_SUBDIR = "profiles"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class ProfileEntry:
|
|
18
|
+
bundle_id: str
|
|
19
|
+
name: str
|
|
20
|
+
uuid: str
|
|
21
|
+
filename: str
|
|
22
|
+
expiration: datetime
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _atomic_write(path: Path, data: bytes) -> None:
|
|
26
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
27
|
+
temp = path.with_suffix(path.suffix + ".tmp")
|
|
28
|
+
temp.write_bytes(data)
|
|
29
|
+
os.replace(temp, path)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _parse_iso(value: str) -> datetime:
|
|
33
|
+
if value.endswith("Z"):
|
|
34
|
+
value = value[:-1] + "+00:00"
|
|
35
|
+
parsed = datetime.fromisoformat(value)
|
|
36
|
+
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def profile_path(creds_dir: Path, uuid: str) -> Path:
|
|
40
|
+
return creds_dir / _PROFILES_SUBDIR / f"{uuid}.mobileprovision"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def load_profile_manifest(creds_dir: Path) -> dict:
|
|
44
|
+
path = creds_dir / _MANIFEST
|
|
45
|
+
if not path.exists():
|
|
46
|
+
return {"cert_id": None, "profiles": []}
|
|
47
|
+
try:
|
|
48
|
+
data = json.loads(path.read_text())
|
|
49
|
+
except (OSError, ValueError) as exc:
|
|
50
|
+
print(f"::warning::profiles.manifest.json unreadable ({exc}); resetting")
|
|
51
|
+
return {"cert_id": None, "profiles": []}
|
|
52
|
+
data.setdefault("cert_id", None)
|
|
53
|
+
data.setdefault("profiles", [])
|
|
54
|
+
return data
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def write_profile_manifest(creds_dir: Path, manifest: dict) -> None:
|
|
58
|
+
profiles = sorted(manifest.get("profiles", []),
|
|
59
|
+
key=lambda entry: entry.get("bundle_id", ""))
|
|
60
|
+
payload = {"cert_id": manifest.get("cert_id"), "profiles": profiles}
|
|
61
|
+
_atomic_write(creds_dir / _MANIFEST, json.dumps(payload, indent=2).encode())
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def find_reusable_profile(manifest: dict, bundle_id: str, cert_id: str,
|
|
65
|
+
creds_dir: Path) -> ProfileEntry | None:
|
|
66
|
+
if manifest.get("cert_id") != cert_id:
|
|
67
|
+
return None
|
|
68
|
+
deadline = datetime.now(timezone.utc) + timedelta(days=RENEW_THRESHOLD_DAYS)
|
|
69
|
+
for raw in manifest.get("profiles", []):
|
|
70
|
+
if raw.get("bundle_id") != bundle_id:
|
|
71
|
+
continue
|
|
72
|
+
try:
|
|
73
|
+
expiration, uuid = _parse_iso(raw["expiration"]), raw["uuid"]
|
|
74
|
+
except (KeyError, ValueError):
|
|
75
|
+
return None
|
|
76
|
+
if expiration <= deadline or not profile_path(creds_dir, uuid).exists():
|
|
77
|
+
return None
|
|
78
|
+
return ProfileEntry(
|
|
79
|
+
bundle_id=bundle_id, name=raw.get("name", ""), uuid=uuid,
|
|
80
|
+
filename=raw.get("filename", f"{uuid}.mobileprovision"),
|
|
81
|
+
expiration=expiration,
|
|
82
|
+
)
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def write_cached_profile(creds_dir: Path, uuid: str, profile_der: bytes) -> None:
|
|
87
|
+
_atomic_write(profile_path(creds_dir, uuid), profile_der)
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Fail-closed behavior for centrally managed distribution identities."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
import tempfile
|
|
10
|
+
import unittest
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from unittest import mock
|
|
14
|
+
|
|
15
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
16
|
+
|
|
17
|
+
import creds_store # noqa: E402
|
|
18
|
+
import prepare_signing # noqa: E402
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class RegistryManagedSigningTests(unittest.TestCase):
|
|
22
|
+
def _managed_dir(self, state: str = "ready") -> tempfile.TemporaryDirectory:
|
|
23
|
+
temp = tempfile.TemporaryDirectory()
|
|
24
|
+
root = Path(temp.name)
|
|
25
|
+
p12, metadata = b"p12", b"metadata"
|
|
26
|
+
(root / "cert.p12").write_bytes(p12)
|
|
27
|
+
(root / "cert.meta.json").write_bytes(metadata)
|
|
28
|
+
marker = root / "cert.registry.json"
|
|
29
|
+
marker.write_text(json.dumps({
|
|
30
|
+
"schema_version": 1,
|
|
31
|
+
"managed_by": "app-robot",
|
|
32
|
+
"state": state,
|
|
33
|
+
"p12_sha256": hashlib.sha256(p12).hexdigest(),
|
|
34
|
+
"metadata_sha256": hashlib.sha256(metadata).hexdigest(),
|
|
35
|
+
}))
|
|
36
|
+
return temp
|
|
37
|
+
|
|
38
|
+
def test_missing_managed_identity_never_enters_creation_path(self):
|
|
39
|
+
with self._managed_dir() as raw:
|
|
40
|
+
creds_dir = Path(raw)
|
|
41
|
+
(creds_dir / "cert.p12").unlink()
|
|
42
|
+
with mock.patch.object(prepare_signing.cert_factory, "generate_key_and_csr") as generate:
|
|
43
|
+
with mock.patch.object(prepare_signing.cert_factory, "create_distribution_cert") as create:
|
|
44
|
+
with mock.patch.object(prepare_signing.creds_store, "invalidate_cache") as invalidate:
|
|
45
|
+
with self.assertRaisesRegex(SystemExit, "bundle is incomplete"):
|
|
46
|
+
prepare_signing._load_or_create_cert("token", creds_dir)
|
|
47
|
+
generate.assert_not_called()
|
|
48
|
+
create.assert_not_called()
|
|
49
|
+
invalidate.assert_not_called()
|
|
50
|
+
|
|
51
|
+
def test_revoked_managed_identity_is_not_deleted_or_replaced(self):
|
|
52
|
+
cached = creds_store.CachedCert(
|
|
53
|
+
cert_id="CERT1",
|
|
54
|
+
not_after=datetime(2027, 8, 1, tzinfo=timezone.utc),
|
|
55
|
+
p12_bytes=b"p12",
|
|
56
|
+
password="secret",
|
|
57
|
+
certificate_sha256="a" * 64,
|
|
58
|
+
)
|
|
59
|
+
with self._managed_dir() as raw:
|
|
60
|
+
with mock.patch.object(prepare_signing.creds_store, "load_cached_cert", return_value=cached):
|
|
61
|
+
with mock.patch.object(prepare_signing.creds_store, "verify_cert_alive", return_value=False):
|
|
62
|
+
with mock.patch.object(prepare_signing.cert_factory, "create_distribution_cert") as create:
|
|
63
|
+
with self.assertRaisesRegex(SystemExit, "not active on Apple or does not match"):
|
|
64
|
+
prepare_signing._load_or_create_cert("token", Path(raw))
|
|
65
|
+
create.assert_not_called()
|
|
66
|
+
|
|
67
|
+
def test_active_managed_identity_is_reused(self):
|
|
68
|
+
cached = creds_store.CachedCert(
|
|
69
|
+
cert_id="CERT1",
|
|
70
|
+
not_after=datetime(2027, 8, 1, tzinfo=timezone.utc),
|
|
71
|
+
p12_bytes=b"p12",
|
|
72
|
+
password="secret",
|
|
73
|
+
certificate_sha256="a" * 64,
|
|
74
|
+
)
|
|
75
|
+
with self._managed_dir() as raw:
|
|
76
|
+
with mock.patch.object(prepare_signing.creds_store, "load_cached_cert", return_value=cached):
|
|
77
|
+
with mock.patch.object(prepare_signing.creds_store, "verify_cert_alive", return_value=True):
|
|
78
|
+
with mock.patch.object(prepare_signing.cert_factory, "create_distribution_cert") as create:
|
|
79
|
+
result = prepare_signing._load_or_create_cert("token", Path(raw))
|
|
80
|
+
self.assertEqual(result, ("CERT1", b"p12", "secret", True))
|
|
81
|
+
create.assert_not_called()
|
|
82
|
+
|
|
83
|
+
def test_managed_liveness_binds_resource_id_to_certificate_bytes(self):
|
|
84
|
+
remote_der = b"apple-certificate-der"
|
|
85
|
+
response = mock.Mock(status_code=200)
|
|
86
|
+
response.json.return_value = {"data": {"attributes": {
|
|
87
|
+
"certificateContent": base64.b64encode(remote_der).decode(),
|
|
88
|
+
}}}
|
|
89
|
+
with mock.patch.object(creds_store, "request", return_value=response):
|
|
90
|
+
digest = hashlib.sha256(remote_der).hexdigest()
|
|
91
|
+
self.assertTrue(creds_store.verify_cert_alive("token", "CERT1", digest))
|
|
92
|
+
self.assertFalse(creds_store.verify_cert_alive("token", "CERT1", "b" * 64))
|
|
93
|
+
|
|
94
|
+
def test_updating_managed_bundle_fails_before_reading_identity(self):
|
|
95
|
+
with self._managed_dir(state="updating") as raw:
|
|
96
|
+
with mock.patch.object(prepare_signing.creds_store, "load_cached_cert") as load:
|
|
97
|
+
with self.assertRaisesRegex(SystemExit, "not committed and ready"):
|
|
98
|
+
prepare_signing._load_or_create_cert("token", Path(raw))
|
|
99
|
+
load.assert_not_called()
|
|
100
|
+
|
|
101
|
+
def test_ready_marker_rejects_tampered_bundle(self):
|
|
102
|
+
with self._managed_dir() as raw:
|
|
103
|
+
(Path(raw) / "cert.p12").write_bytes(b"tampered")
|
|
104
|
+
with self.assertRaisesRegex(SystemExit, "digest mismatch"):
|
|
105
|
+
prepare_signing._load_or_create_cert("token", Path(raw))
|
|
106
|
+
|
|
107
|
+
def test_metadata_without_ownership_marker_remains_legacy(self):
|
|
108
|
+
with tempfile.TemporaryDirectory() as raw:
|
|
109
|
+
meta = Path(raw) / "cert.meta.json"
|
|
110
|
+
meta.write_text(json.dumps({"managed_by": "app-robot"}))
|
|
111
|
+
self.assertFalse(creds_store.registry_managed(Path(raw)))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
if __name__ == "__main__":
|
|
115
|
+
unittest.main()
|
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.40
|
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.40
|