Issue signing certificates without the developer portal

First slice of the fastlane match replacement, chosen because it is the part nothing else can substitute for: generating a key pair, getting Apple to certify it, and keeping both halves. Apple never gives you a private key. You generate one locally, send only the public half inside a CSR, and Apple returns a certificate that vouches for it. So a certificate whose key is lost is dead permanently, and an expired certificate cannot be renewed at all: there is no such endpoint, only creating a new one, which means a new key and a new CSR. That is why the portal's Download button cannot rescue anybody, and why this command writes the key to disk before it calls Apple. Failing after the request would leave a certificate whose key we never persisted, which is unrecoverable; failing before it leaves an unused key, which costs nothing. The CSR must be RSA 2048. Apple rejects ECDSA, which is what most modern tooling emits by default. rcgen cannot generate RSA keys, so the key comes from the rsa crate as PKCS#8 PEM and rcgen re-parses it purely to produce the self-signature. The test asserts the result through openssl rather than through our own types, since checking our output with our own parser would prove nothing. It reads stdout and stderr together: macOS ships LibreSSL, which prints the verify banner to stderr while OpenSSL proper puts it on stdout, and reading one stream makes the test depend on which implementation the machine has. Key material is Zeroizing, Debug is hand-written to redact it, and the private key file is opened 0600 at creation rather than chmod'ed after, so it is never briefly world-readable. certificateContent stays out of the list output: it is large, and printing it invites pasting certificate bodies somewhere they should not go. Revoking asks for the id twice. It is irreversible and breaks every provisioning profile embedding that certificate for the whole team at once, including builds already running in CI, so it should be hard to do by accident and impossible to do by autocomplete. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PGeEFM4hBGSZYXjFWguaWa

Seto Elkahfi committed Aug 6, 2026 at 07:20 UTC c84bc522f3a0e04d5ef2b8b4cabbd0f84b6017a4
9 files changed +1222 -14
Cargo.lock
+468 -7
@@ -58,6 +58,45 @@ version = "1.0.103"
58 source = "registry+https://github.com/rust-lang/crates.io-index"
59 checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
60
61 +[[package]]
62 +name = "asn1-rs"
63 +version = "0.7.2"
64 +source = "registry+https://github.com/rust-lang/crates.io-index"
65 +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8"
66 +dependencies = [
67 + "asn1-rs-derive",
68 + "asn1-rs-impl",
69 + "displaydoc",
70 + "nom",
71 + "num-traits",
72 + "rusticata-macros",
73 + "thiserror",
74 + "time",
75 +]
76 +
77 +[[package]]
78 +name = "asn1-rs-derive"
79 +version = "0.6.0"
80 +source = "registry+https://github.com/rust-lang/crates.io-index"
81 +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c"
82 +dependencies = [
83 + "proc-macro2",
84 + "quote",
85 + "syn",
86 + "synstructure",
87 +]
88 +
89 +[[package]]
90 +name = "asn1-rs-impl"
91 +version = "0.2.0"
92 +source = "registry+https://github.com/rust-lang/crates.io-index"
93 +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7"
94 +dependencies = [
95 + "proc-macro2",
96 + "quote",
97 + "syn",
98 +]
99 +
100 [[package]]
101 name = "atomic-waker"
102 version = "1.1.2"
@@ -70,12 +109,51 @@ version = "1.5.1"
109 source = "registry+https://github.com/rust-lang/crates.io-index"
110 checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
111
112 +[[package]]
113 +name = "aws-lc-rs"
114 +version = "1.17.3"
115 +source = "registry+https://github.com/rust-lang/crates.io-index"
116 +checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1"
117 +dependencies = [
118 + "aws-lc-sys",
119 + "untrusted 0.7.1",
120 + "zeroize",
121 +]
122 +
123 +[[package]]
124 +name = "aws-lc-sys"
125 +version = "0.43.0"
126 +source = "registry+https://github.com/rust-lang/crates.io-index"
127 +checksum = "43103168cc76fe62678a375e722fc9cb3a0146159ac5828bc4f0dfd755c2224c"
128 +dependencies = [
129 + "cc",
130 + "cmake",
131 + "dunce",
132 + "fs_extra",
133 + "pkg-config",
134 +]
135 +
136 [[package]]
137 name = "base64"
138 version = "0.22.1"
139 source = "registry+https://github.com/rust-lang/crates.io-index"
140 checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
141
142 +[[package]]
143 +name = "base64ct"
144 +version = "1.8.3"
145 +source = "registry+https://github.com/rust-lang/crates.io-index"
146 +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
147 +
148 +[[package]]
149 +name = "bit-vec"
150 +version = "0.9.1"
151 +source = "registry+https://github.com/rust-lang/crates.io-index"
152 +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51"
153 +dependencies = [
154 + "serde",
155 +]
156 +
157 [[package]]
158 name = "bitflags"
159 version = "2.13.1"
@@ -101,6 +179,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
179 checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38"
180 dependencies = [
181 "find-msvc-tools",
182 + "jobserver",
183 + "libc",
184 "shlex",
185 ]
186
@@ -124,7 +204,7 @@ checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
204 dependencies = [
205 "cfg-if",
206 "cpufeatures",
127 - "rand_core",
207 + "rand_core 0.10.1",
208 ]
209
210 [[package]]
@@ -167,12 +247,27 @@ version = "1.1.0"
247 source = "registry+https://github.com/rust-lang/crates.io-index"
248 checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
249
250 +[[package]]
251 +name = "cmake"
252 +version = "0.1.58"
253 +source = "registry+https://github.com/rust-lang/crates.io-index"
254 +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
255 +dependencies = [
256 + "cc",
257 +]
258 +
259 [[package]]
260 name = "colorchoice"
261 version = "1.0.5"
262 source = "registry+https://github.com/rust-lang/crates.io-index"
263 checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
264
265 +[[package]]
266 +name = "const-oid"
267 +version = "0.9.6"
268 +source = "registry+https://github.com/rust-lang/crates.io-index"
269 +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
270 +
271 [[package]]
272 name = "core-foundation"
273 version = "0.10.1"
@@ -198,12 +293,63 @@ dependencies = [
293 "libc",
294 ]
295
296 +[[package]]
297 +name = "crypto-common"
298 +version = "0.1.7"
299 +source = "registry+https://github.com/rust-lang/crates.io-index"
300 +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
301 +dependencies = [
302 + "generic-array",
303 + "typenum",
304 +]
305 +
306 +[[package]]
307 +name = "data-encoding"
308 +version = "2.11.1"
309 +source = "registry+https://github.com/rust-lang/crates.io-index"
310 +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
311 +
312 +[[package]]
313 +name = "der"
314 +version = "0.7.10"
315 +source = "registry+https://github.com/rust-lang/crates.io-index"
316 +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
317 +dependencies = [
318 + "const-oid",
319 + "pem-rfc7468",
320 + "zeroize",
321 +]
322 +
323 +[[package]]
324 +name = "der-parser"
325 +version = "10.0.0"
326 +source = "registry+https://github.com/rust-lang/crates.io-index"
327 +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6"
328 +dependencies = [
329 + "asn1-rs",
330 + "displaydoc",
331 + "nom",
332 + "num-bigint",
333 + "num-traits",
334 + "rusticata-macros",
335 +]
336 +
337 [[package]]
338 name = "deranged"
339 version = "0.5.8"
340 source = "registry+https://github.com/rust-lang/crates.io-index"
341 checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
342
343 +[[package]]
344 +name = "digest"
345 +version = "0.10.7"
346 +source = "registry+https://github.com/rust-lang/crates.io-index"
347 +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
348 +dependencies = [
349 + "const-oid",
350 + "crypto-common",
351 +]
352 +
353 [[package]]
354 name = "displaydoc"
355 version = "0.2.6"
@@ -215,6 +361,12 @@ dependencies = [
361 "syn",
362 ]
363
364 +[[package]]
365 +name = "dunce"
366 +version = "1.0.5"
367 +source = "registry+https://github.com/rust-lang/crates.io-index"
368 +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
369 +
370 [[package]]
371 name = "find-msvc-tools"
372 version = "0.1.9"
@@ -230,6 +382,12 @@ dependencies = [
382 "percent-encoding",
383 ]
384
385 +[[package]]
386 +name = "fs_extra"
387 +version = "1.3.0"
388 +source = "registry+https://github.com/rust-lang/crates.io-index"
389 +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
390 +
391 [[package]]
392 name = "futures-channel"
393 version = "0.3.32"
@@ -263,6 +421,16 @@ dependencies = [
421 "slab",
422 ]
423
424 +[[package]]
425 +name = "generic-array"
426 +version = "0.14.7"
427 +source = "registry+https://github.com/rust-lang/crates.io-index"
428 +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
429 +dependencies = [
430 + "typenum",
431 + "version_check",
432 +]
433 +
434 [[package]]
435 name = "getrandom"
436 version = "0.2.17"
@@ -286,7 +454,7 @@ dependencies = [
454 "js-sys",
455 "libc",
456 "r-efi",
289 - "rand_core",
457 + "rand_core 0.10.1",
458 "wasm-bindgen",
459 ]
460
@@ -515,6 +683,16 @@ version = "1.0.18"
683 source = "registry+https://github.com/rust-lang/crates.io-index"
684 checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
685
686 +[[package]]
687 +name = "jobserver"
688 +version = "0.1.35"
689 +source = "registry+https://github.com/rust-lang/crates.io-index"
690 +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
691 +dependencies = [
692 + "getrandom 0.4.3",
693 + "libc",
694 +]
695 +
696 [[package]]
697 name = "js-sys"
698 version = "0.3.103"
@@ -541,12 +719,27 @@ dependencies = [
719 "simple_asn1",
720 ]
721
722 +[[package]]
723 +name = "lazy_static"
724 +version = "1.5.0"
725 +source = "registry+https://github.com/rust-lang/crates.io-index"
726 +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
727 +dependencies = [
728 + "spin",
729 +]
730 +
731 [[package]]
732 name = "libc"
733 version = "0.2.186"
734 source = "registry+https://github.com/rust-lang/crates.io-index"
735 checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
736
737 +[[package]]
738 +name = "libm"
739 +version = "0.2.16"
740 +source = "registry+https://github.com/rust-lang/crates.io-index"
741 +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
742 +
743 [[package]]
744 name = "litemap"
745 version = "0.8.2"
@@ -577,6 +770,12 @@ version = "2.8.3"
770 source = "registry+https://github.com/rust-lang/crates.io-index"
771 checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
772
773 +[[package]]
774 +name = "minimal-lexical"
775 +version = "0.2.1"
776 +source = "registry+https://github.com/rust-lang/crates.io-index"
777 +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
778 +
779 [[package]]
780 name = "mio"
781 version = "1.2.2"
@@ -588,6 +787,16 @@ dependencies = [
787 "windows-sys 0.61.2",
788 ]
789
790 +[[package]]
791 +name = "nom"
792 +version = "7.1.3"
793 +source = "registry+https://github.com/rust-lang/crates.io-index"
794 +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
795 +dependencies = [
796 + "memchr",
797 + "minimal-lexical",
798 +]
799 +
800 [[package]]
801 name = "num-bigint"
802 version = "0.4.8"
@@ -598,6 +807,22 @@ dependencies = [
807 "num-traits",
808 ]
809
810 +[[package]]
811 +name = "num-bigint-dig"
812 +version = "0.8.6"
813 +source = "registry+https://github.com/rust-lang/crates.io-index"
814 +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7"
815 +dependencies = [
816 + "lazy_static",
817 + "libm",
818 + "num-integer",
819 + "num-iter",
820 + "num-traits",
821 + "rand 0.8.7",
822 + "smallvec",
823 + "zeroize",
824 +]
825 +
826 [[package]]
827 name = "num-conv"
828 version = "0.2.2"
@@ -613,6 +838,16 @@ dependencies = [
838 "num-traits",
839 ]
840
841 +[[package]]
842 +name = "num-iter"
843 +version = "0.1.46"
844 +source = "registry+https://github.com/rust-lang/crates.io-index"
845 +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b"
846 +dependencies = [
847 + "num-integer",
848 + "num-traits",
849 +]
850 +
851 [[package]]
852 name = "num-traits"
853 version = "0.2.19"
@@ -620,6 +855,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
855 checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
856 dependencies = [
857 "autocfg",
858 + "libm",
859 +]
860 +
861 +[[package]]
862 +name = "oid-registry"
863 +version = "0.8.1"
864 +source = "registry+https://github.com/rust-lang/crates.io-index"
865 +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7"
866 +dependencies = [
867 + "asn1-rs",
868 ]
869
870 [[package]]
@@ -650,6 +895,15 @@ dependencies = [
895 "serde_core",
896 ]
897
898 +[[package]]
899 +name = "pem-rfc7468"
900 +version = "0.7.0"
901 +source = "registry+https://github.com/rust-lang/crates.io-index"
902 +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
903 +dependencies = [
904 + "base64ct",
905 +]
906 +
907 [[package]]
908 name = "percent-encoding"
909 version = "2.3.2"
@@ -662,6 +916,33 @@ version = "0.2.17"
916 source = "registry+https://github.com/rust-lang/crates.io-index"
917 checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
918
919 +[[package]]
920 +name = "pkcs1"
921 +version = "0.7.5"
922 +source = "registry+https://github.com/rust-lang/crates.io-index"
923 +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f"
924 +dependencies = [
925 + "der",
926 + "pkcs8",
927 + "spki",
928 +]
929 +
930 +[[package]]
931 +name = "pkcs8"
932 +version = "0.10.2"
933 +source = "registry+https://github.com/rust-lang/crates.io-index"
934 +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
935 +dependencies = [
936 + "der",
937 + "spki",
938 +]
939 +
940 +[[package]]
941 +name = "pkg-config"
942 +version = "0.3.33"
943 +source = "registry+https://github.com/rust-lang/crates.io-index"
944 +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
945 +
946 [[package]]
947 name = "potential_utf"
948 version = "0.1.5"
@@ -677,6 +958,15 @@ version = "0.2.0"
958 source = "registry+https://github.com/rust-lang/crates.io-index"
959 checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
960
961 +[[package]]
962 +name = "ppv-lite86"
963 +version = "0.2.21"
964 +source = "registry+https://github.com/rust-lang/crates.io-index"
965 +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
966 +dependencies = [
967 + "zerocopy",
968 +]
969 +
970 [[package]]
971 name = "proc-macro2"
972 version = "1.0.106"
@@ -715,7 +1005,7 @@ dependencies = [
1005 "bytes",
1006 "getrandom 0.4.3",
1007 "lru-slab",
718 - "rand",
1008 + "rand 0.10.2",
1009 "rand_pcg",
1010 "ring",
1011 "rustc-hash",
@@ -757,6 +1047,17 @@ version = "6.0.0"
1047 source = "registry+https://github.com/rust-lang/crates.io-index"
1048 checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
1049
1050 +[[package]]
1051 +name = "rand"
1052 +version = "0.8.7"
1053 +source = "registry+https://github.com/rust-lang/crates.io-index"
1054 +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a"
1055 +dependencies = [
1056 + "libc",
1057 + "rand_chacha",
1058 + "rand_core 0.6.4",
1059 +]
1060 +
1061 [[package]]
1062 name = "rand"
1063 version = "0.10.2"
@@ -765,7 +1066,26 @@ checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
1066 dependencies = [
1067 "chacha20",
1068 "getrandom 0.4.3",
768 - "rand_core",
1069 + "rand_core 0.10.1",
1070 +]
1071 +
1072 +[[package]]
1073 +name = "rand_chacha"
1074 +version = "0.3.1"
1075 +source = "registry+https://github.com/rust-lang/crates.io-index"
1076 +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
1077 +dependencies = [
1078 + "ppv-lite86",
1079 + "rand_core 0.6.4",
1080 +]
1081 +
1082 +[[package]]
1083 +name = "rand_core"
1084 +version = "0.6.4"
1085 +source = "registry+https://github.com/rust-lang/crates.io-index"
1086 +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
1087 +dependencies = [
1088 + "getrandom 0.2.17",
1089 ]
1090
1091 [[package]]
@@ -780,7 +1100,21 @@ version = "0.10.2"
1100 source = "registry+https://github.com/rust-lang/crates.io-index"
1101 checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
1102 dependencies = [
783 - "rand_core",
1103 + "rand_core 0.10.1",
1104 +]
1105 +
1106 +[[package]]
1107 +name = "rcgen"
1108 +version = "0.14.8"
1109 +source = "registry+https://github.com/rust-lang/crates.io-index"
1110 +checksum = "57f6d249aad744e274e682777a50283a225a32705394ee6d5fcc01efa25e4055"
1111 +dependencies = [
1112 + "aws-lc-rs",
1113 + "pem",
1114 + "rustls-pki-types",
1115 + "time",
1116 + "x509-parser",
1117 + "yasna",
1118 ]
1119
1120 [[package]]
@@ -831,16 +1165,45 @@ dependencies = [
1165 "cfg-if",
1166 "getrandom 0.2.17",
1167 "libc",
834 - "untrusted",
1168 + "untrusted 0.9.0",
1169 "windows-sys 0.52.0",
1170 ]
1171
1172 +[[package]]
1173 +name = "rsa"
1174 +version = "0.9.10"
1175 +source = "registry+https://github.com/rust-lang/crates.io-index"
1176 +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d"
1177 +dependencies = [
1178 + "const-oid",
1179 + "digest",
1180 + "num-bigint-dig",
1181 + "num-integer",
1182 + "num-traits",
1183 + "pkcs1",
1184 + "pkcs8",
1185 + "rand_core 0.6.4",
1186 + "signature",
1187 + "spki",
1188 + "subtle",
1189 + "zeroize",
1190 +]
1191 +
1192 [[package]]
1193 name = "rustc-hash"
1194 version = "2.1.3"
1195 source = "registry+https://github.com/rust-lang/crates.io-index"
1196 checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
1197
1198 +[[package]]
1199 +name = "rusticata-macros"
1200 +version = "4.1.0"
1201 +source = "registry+https://github.com/rust-lang/crates.io-index"
1202 +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632"
1203 +dependencies = [
1204 + "nom",
1205 +]
1206 +
1207 [[package]]
1208 name = "rustls"
1209 version = "0.23.42"
@@ -885,7 +1248,7 @@ checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
1248 dependencies = [
1249 "ring",
1250 "rustls-pki-types",
888 - "untrusted",
1251 + "untrusted 0.9.0",
1252 ]
1253
1254 [[package]]
@@ -993,6 +1356,16 @@ version = "2.0.1"
1356 source = "registry+https://github.com/rust-lang/crates.io-index"
1357 checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
1358
1359 +[[package]]
1360 +name = "signature"
1361 +version = "2.2.0"
1362 +source = "registry+https://github.com/rust-lang/crates.io-index"
1363 +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
1364 +dependencies = [
1365 + "digest",
1366 + "rand_core 0.6.4",
1367 +]
1368 +
1369 [[package]]
1370 name = "simple_asn1"
1371 version = "0.6.4"
@@ -1021,13 +1394,18 @@ checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
1394 name = "smbcloud-ascapi"
1395 version = "0.1.0"
1396 dependencies = [
1397 + "base64",
1398 "jsonwebtoken",
1399 "md5",
1400 + "rand 0.8.7",
1401 + "rcgen",
1402 "reqwest",
1403 + "rsa",
1404 "serde",
1405 "serde_json",
1406 "thiserror",
1407 "tokio",
1408 + "zeroize",
1409 ]
1410
1411 [[package]]
@@ -1035,6 +1413,7 @@ name = "smbcloud-ascapi-cli"
1413 version = "0.1.0"
1414 dependencies = [
1415 "anyhow",
1416 + "base64",
1417 "clap",
1418 "serde",
1419 "serde_json",
@@ -1052,6 +1431,22 @@ dependencies = [
1431 "windows-sys 0.61.2",
1432 ]
1433
1434 +[[package]]
1435 +name = "spin"
1436 +version = "0.9.9"
1437 +source = "registry+https://github.com/rust-lang/crates.io-index"
1438 +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
1439 +
1440 +[[package]]
1441 +name = "spki"
1442 +version = "0.7.3"
1443 +source = "registry+https://github.com/rust-lang/crates.io-index"
1444 +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
1445 +dependencies = [
1446 + "base64ct",
1447 + "der",
1448 +]
1449 +
1450 [[package]]
1451 name = "stable_deref_trait"
1452 version = "1.2.1"
@@ -1282,12 +1677,24 @@ version = "0.2.5"
1677 source = "registry+https://github.com/rust-lang/crates.io-index"
1678 checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
1679
1680 +[[package]]
1681 +name = "typenum"
1682 +version = "1.20.1"
1683 +source = "registry+https://github.com/rust-lang/crates.io-index"
1684 +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
1685 +
1686 [[package]]
1687 name = "unicode-ident"
1688 version = "1.0.24"
1689 source = "registry+https://github.com/rust-lang/crates.io-index"
1690 checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
1691
1692 +[[package]]
1693 +name = "untrusted"
1694 +version = "0.7.1"
1695 +source = "registry+https://github.com/rust-lang/crates.io-index"
1696 +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a"
1697 +
1698 [[package]]
1699 name = "untrusted"
1700 version = "0.9.0"
@@ -1318,6 +1725,12 @@ version = "0.2.2"
1725 source = "registry+https://github.com/rust-lang/crates.io-index"
1726 checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
1727
1728 +[[package]]
1729 +name = "version_check"
1730 +version = "0.9.5"
1731 +source = "registry+https://github.com/rust-lang/crates.io-index"
1732 +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
1733 +
1734 [[package]]
1735 name = "want"
1736 version = "0.3.1"
@@ -1502,6 +1915,34 @@ version = "0.6.3"
1915 source = "registry+https://github.com/rust-lang/crates.io-index"
1916 checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
1917
1918 +[[package]]
1919 +name = "x509-parser"
1920 +version = "0.18.1"
1921 +source = "registry+https://github.com/rust-lang/crates.io-index"
1922 +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202"
1923 +dependencies = [
1924 + "asn1-rs",
1925 + "aws-lc-rs",
1926 + "data-encoding",
1927 + "der-parser",
1928 + "lazy_static",
1929 + "nom",
1930 + "oid-registry",
1931 + "rusticata-macros",
1932 + "thiserror",
1933 + "time",
1934 +]
1935 +
1936 +[[package]]
1937 +name = "yasna"
1938 +version = "0.6.0"
1939 +source = "registry+https://github.com/rust-lang/crates.io-index"
1940 +checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282"
1941 +dependencies = [
1942 + "bit-vec",
1943 + "time",
1944 +]
1945 +
1946 [[package]]
1947 name = "yoke"
1948 version = "0.8.3"
@@ -1525,6 +1966,26 @@ dependencies = [
1966 "synstructure",
1967 ]
1968
1969 +[[package]]
1970 +name = "zerocopy"
1971 +version = "0.8.55"
1972 +source = "registry+https://github.com/rust-lang/crates.io-index"
1973 +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb"
1974 +dependencies = [
1975 + "zerocopy-derive",
1976 +]
1977 +
1978 +[[package]]
1979 +name = "zerocopy-derive"
1980 +version = "0.8.55"
1981 +source = "registry+https://github.com/rust-lang/crates.io-index"
1982 +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb"
1983 +dependencies = [
1984 + "proc-macro2",
1985 + "quote",
1986 + "syn",
1987 +]
1988 +
1989 [[package]]
1990 name = "zerofrom"
1991 version = "0.1.8"
Cargo.toml
+5
@@ -19,3 +19,8 @@ serde = "1"
19 serde_json = "1"
20 thiserror = "2.0"
21 tokio = "1"
22 +base64 = "0.22"
23 +rcgen = { version = "0.14", default-features = false, features = ["pem", "crypto", "aws_lc_rs"] }
24 +rsa = "0.9"
25 +rand = "0.8"
26 +zeroize = { version = "1", features = ["alloc"] }
crates/ascapi/Cargo.toml
+5
@@ -12,12 +12,17 @@ readme = "README.md"
12 path = "src/ascapi.rs"
13
14 [dependencies]
15 +base64 = { workspace = true }
16 jsonwebtoken = { workspace = true }
17 +rand = { workspace = true }
18 +rcgen = { workspace = true }
19 +rsa = { workspace = true, features = ["pem"] }
20 md5 = "0.7"
21 reqwest = { workspace = true, features = ["json", "rustls-tls-native-roots"] }
22 serde = { workspace = true, features = ["derive"] }
23 serde_json = { workspace = true }
24 thiserror = { workspace = true }
25 +zeroize = { workspace = true }
26
27 [dev-dependencies]
28 tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
crates/ascapi/src/ascapi.rs
+13 -7
@@ -1,8 +1,12 @@
1 //! Embeddable Rust client (and CLI, in the sibling `smbcloud-ascapi-cli`
2 -//! crate / `ascapi` binary) for the App Store Connect API's
3 -//! [App Metadata](https://developer.apple.com/documentation/appstoreconnectapi/app-metadata)
4 -//! resources: apps, app infos and their localizations, app store versions
5 -//! and their localizations, and bundle IDs.
2 +//! crate / `ascapi` binary) for two slices of the App Store Connect API:
3 +//!
4 +//! - [App Metadata](https://developer.apple.com/documentation/appstoreconnectapi/app-metadata):
5 +//! apps, app infos and their localizations, app store versions and their
6 +//! localizations, and bundle IDs.
7 +//! - Signing certificates ([`certificate`]), plus local RSA key pair and
8 +//! CSR generation ([`csr`]) so a certificate can be issued end to end
9 +//! without the developer portal.
10 //!
11 //! ```no_run
12 //! use smbcloud_ascapi::{ApiKey, Client};
@@ -33,9 +37,9 @@
37 //! # }
38 //! ```
39 //!
36 -//! Not covered (yet): app preview (video) binary uploads, age rating
37 -//! declarations, in-app purchases, TestFlight, and the rest of the wider
38 -//! App Store Connect API surface outside "App Metadata".
40 +//! Not covered (yet): provisioning profiles and devices (the other half of
41 +//! the Provisioning surface), app preview (video) binary uploads, age
42 +//! rating declarations, in-app purchases, and TestFlight.
43
44 pub mod app;
45 pub mod app_info;
@@ -47,7 +51,9 @@ pub mod app_store_version_localization;
51 pub mod auth;
52 pub mod build;
53 pub mod bundle_id;
54 +pub mod certificate;
55 pub mod client;
56 +pub mod csr;
57 pub mod error;
58 pub mod jsonapi;
59
crates/ascapi/src/certificate.rs new
+230
@@ -0,0 +1,230 @@
1 +//! Signing certificates: `/v1/certificates`.
2 +//!
3 +//! The one thing to understand before using this module: **Apple never
4 +//! gives you a private key.** You generate an RSA key pair locally, send
5 +//! Apple only the public half inside a Certificate Signing Request, and
6 +//! Apple returns a certificate that vouches for it. The private key never
7 +//! leaves the machine that made it.
8 +//!
9 +//! Two consequences follow, and both are the reason a tool like this
10 +//! exists:
11 +//!
12 +//! - Losing the private key makes its certificate permanently useless.
13 +//! Re-downloading from the developer portal cannot recover it, because
14 +//! Apple never had it.
15 +//! - An expired certificate cannot be renewed. There is no such endpoint.
16 +//! You create a new one, which means a new key pair and a new CSR, and
17 +//! every provisioning profile embedding the old certificate has to be
18 +//! regenerated.
19 +//!
20 +//! See [`crate::csr`] for producing the `csr_content` that
21 +//! [`Client::create_certificate`] wants.
22 +
23 +use crate::client::Client;
24 +use crate::error::Result;
25 +use crate::jsonapi::{CreateBody, CreateData, Document, ListDocument, Resource};
26 +use reqwest::Method;
27 +use serde::{Deserialize, Serialize};
28 +
29 +pub const RESOURCE_TYPE: &str = "certificates";
30 +
31 +/// The kinds of certificate App Store Connect can issue.
32 +///
33 +/// `Development` and `Distribution` are the modern, platform-agnostic
34 +/// pair: one Apple Development certificate covers iOS, macOS, tvOS, and
35 +/// visionOS development, and one Apple Distribution certificate covers
36 +/// distribution for all of them. The `Ios*` and `Mac*` variants are the
37 +/// older per-platform types, still issued and still required in two
38 +/// cases worth knowing:
39 +///
40 +/// - [`Self::MacInstallerDistribution`] signs the installer package for a
41 +/// Mac App Store submission (`productbuild --sign`). Nothing else can,
42 +/// so a Mac App Store release needs it alongside a distribution
43 +/// certificate for the app bundle itself.
44 +/// - [`Self::DeveloperIdApplication`] and [`Self::DeveloperIdKext`] are for
45 +/// distribution outside the App Store, and are capped per team in a way
46 +/// the others are not.
47 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
48 +pub enum CertificateType {
49 + /// Apple Development: one certificate for development across every
50 + /// Apple platform.
51 + #[serde(rename = "DEVELOPMENT")]
52 + Development,
53 + /// Apple Distribution: one certificate for App Store and Ad Hoc
54 + /// distribution across every Apple platform.
55 + #[serde(rename = "DISTRIBUTION")]
56 + Distribution,
57 + #[serde(rename = "IOS_DEVELOPMENT")]
58 + IosDevelopment,
59 + #[serde(rename = "IOS_DISTRIBUTION")]
60 + IosDistribution,
61 + #[serde(rename = "MAC_APP_DEVELOPMENT")]
62 + MacAppDevelopment,
63 + /// "3rd Party Mac Developer Application": signs the `.app` bundle of a
64 + /// Mac App Store submission.
65 + #[serde(rename = "MAC_APP_DISTRIBUTION")]
66 + MacAppDistribution,
67 + /// "3rd Party Mac Developer Installer": signs the `.pkg` a Mac App
68 + /// Store submission is delivered in. Required by `productbuild
69 + /// --sign`; no other certificate type substitutes for it.
70 + #[serde(rename = "MAC_INSTALLER_DISTRIBUTION")]
71 + MacInstallerDistribution,
72 + #[serde(rename = "DEVELOPER_ID_APPLICATION")]
73 + DeveloperIdApplication,
74 + #[serde(rename = "DEVELOPER_ID_KEXT")]
75 + DeveloperIdKext,
76 +}
77 +
78 +impl CertificateType {
79 + /// The string App Store Connect uses, for building `filter[certificateType]`
80 + /// query values without going through serde.
81 + pub fn as_api_str(self) -> &'static str {
82 + match self {
83 + Self::Development => "DEVELOPMENT",
84 + Self::Distribution => "DISTRIBUTION",
85 + Self::IosDevelopment => "IOS_DEVELOPMENT",
86 + Self::IosDistribution => "IOS_DISTRIBUTION",
87 + Self::MacAppDevelopment => "MAC_APP_DEVELOPMENT",
88 + Self::MacAppDistribution => "MAC_APP_DISTRIBUTION",
89 + Self::MacInstallerDistribution => "MAC_INSTALLER_DISTRIBUTION",
90 + Self::DeveloperIdApplication => "DEVELOPER_ID_APPLICATION",
91 + Self::DeveloperIdKext => "DEVELOPER_ID_KEXT",
92 + }
93 + }
94 +}
95 +
96 +#[derive(Debug, Clone, Serialize, Deserialize)]
97 +#[serde(rename_all = "camelCase")]
98 +pub struct CertificateAttributes {
99 + /// The certificate's common name, e.g.
100 + /// `Apple Distribution: Splitfire AB (2TQF86ZACD)`. This is what
101 + /// `codesign --sign` matches when given a name rather than a hash.
102 + pub name: Option<String>,
103 + pub certificate_type: Option<CertificateType>,
104 + pub display_name: Option<String>,
105 + pub serial_number: Option<String>,
106 + pub platform: Option<String>,
107 + /// ISO-8601. Apple reports this directly, so callers never need to
108 + /// parse the DER to find out when a certificate dies.
109 + pub expiration_date: Option<String>,
110 + /// Base64-encoded DER of the certificate itself (the public half).
111 + /// Absent from some list responses; always present on create.
112 + pub certificate_content: Option<String>,
113 +}
114 +
115 +pub type Certificate = Resource<CertificateAttributes>;
116 +
117 +#[derive(Debug, Clone, Serialize)]
118 +#[serde(rename_all = "camelCase")]
119 +pub struct CertificateCreateAttributes {
120 + /// PEM-encoded PKCS#10 Certificate Signing Request. Apple requires the
121 + /// key to be RSA 2048; see [`crate::csr`].
122 + pub csr_content: String,
123 + pub certificate_type: CertificateType,
124 +}
125 +
126 +impl Client {
127 + /// `GET /v1/certificates`, optionally filtered by type.
128 + ///
129 + /// Apple returns expired certificates too, and gives no filter for
130 + /// "valid" — read [`CertificateAttributes::expiration_date`] and decide
131 + /// for yourself. That is deliberate on their side: an expired
132 + /// certificate is still meaningful for identifying what a previously
133 + /// shipped build was signed with.
134 + pub async fn list_certificates(
135 + &self,
136 + filter_type: Option<CertificateType>,
137 + ) -> Result<Vec<Certificate>> {
138 + let mut query = Vec::new();
139 + if let Some(certificate_type) = filter_type {
140 + query.push(("filter[certificateType]", certificate_type.as_api_str()));
141 + }
142 + // Apple's default page size is 20, which a team with several
143 + // platforms and a few years of history will exceed.
144 + query.push(("limit", "200"));
145 +
146 + let doc: ListDocument<CertificateAttributes> = self
147 + .request(Method::GET, "/v1/certificates", &query, None::<&()>)
148 + .await?;
149 + Ok(doc.data)
150 + }
151 +
152 + /// `POST /v1/certificates` — issues a new certificate for a CSR.
153 + ///
154 + /// The response carries `certificate_content`, the only copy of the
155 + /// signed certificate you are handed at creation time. It can be
156 + /// fetched again later with [`Client::list_certificates`], but the
157 + /// private key it belongs to cannot, so store the two together
158 + /// immediately.
159 + ///
160 + /// Apple caps how many certificates of each type a team may hold at
161 + /// once (two for distribution). At the cap this returns a 409; revoke
162 + /// something with [`Client::revoke_certificate`] first.
163 + pub async fn create_certificate(
164 + &self,
165 + attributes: CertificateCreateAttributes,
166 + ) -> Result<Certificate> {
167 + let body = CreateBody {
168 + data: CreateData {
169 + resource_type: RESOURCE_TYPE,
170 + attributes,
171 + relationships: None::<()>,
172 + },
173 + };
174 + let doc: Document<CertificateAttributes> = self
175 + .request(Method::POST, "/v1/certificates", &[], Some(&body))
176 + .await?;
177 + Ok(doc.data)
178 + }
179 +
180 + /// `DELETE /v1/certificates/{id}` — revokes a certificate.
181 + ///
182 + /// Irreversible, and wider-reaching than it looks: every provisioning
183 + /// profile that embeds this certificate stops working for everyone on
184 + /// the team at once, including builds already in CI. Revoking is for
185 + /// reclaiming a slot at Apple's per-type cap or for a key you believe
186 + /// is compromised, not for tidying up.
187 + pub async fn revoke_certificate(&self, id: &str) -> Result<()> {
188 + self.request_no_content(
189 + Method::DELETE,
190 + &format!("/v1/certificates/{id}"),
191 + &[],
192 + None::<&()>,
193 + )
194 + .await
195 + }
196 +}
197 +
198 +#[cfg(test)]
199 +mod tests {
200 + use super::*;
201 +
202 + #[test]
203 + fn certificate_type_round_trips_through_the_api_wire_format() {
204 + // The serde rename and as_api_str() are two separate spellings of
205 + // the same contract, and a filter built from the wrong one fails
206 + // silently by matching nothing.
207 + for certificate_type in [
208 + CertificateType::Development,
209 + CertificateType::Distribution,
210 + CertificateType::IosDevelopment,
211 + CertificateType::IosDistribution,
212 + CertificateType::MacAppDevelopment,
213 + CertificateType::MacAppDistribution,
214 + CertificateType::MacInstallerDistribution,
215 + CertificateType::DeveloperIdApplication,
216 + CertificateType::DeveloperIdKext,
217 + ] {
218 + let json = serde_json::to_string(&certificate_type).expect("serializes");
219 + let unquoted = json.trim_matches('"');
220 + assert_eq!(
221 + unquoted,
222 + certificate_type.as_api_str(),
223 + "serde and as_api_str disagree for {certificate_type:?}"
224 + );
225 +
226 + let parsed: CertificateType = serde_json::from_str(&json).expect("deserializes");
227 + assert_eq!(parsed, certificate_type);
228 + }
229 + }
230 +}
crates/ascapi/src/csr.rs new
+225
@@ -0,0 +1,225 @@
1 +//! Certificate Signing Requests, and the key pairs behind them.
2 +//!
3 +//! A CSR is the only thing you ever send Apple when asking for a signing
4 +//! certificate. It carries the *public* half of a freshly generated key
5 +//! pair plus a subject name, self-signed to prove you hold the matching
6 +//! private key. Apple checks that signature, then issues a certificate
7 +//! binding your public key to your team identity.
8 +//!
9 +//! The private half never leaves this process except through
10 +//! [`KeyPair::private_key_pem`], and it is the part that actually matters:
11 +//! a certificate whose private key is lost is dead, permanently, because
12 +//! Apple never had a copy to give back.
13 +//!
14 +//! # Why RSA 2048
15 +//!
16 +//! Apple's certificate service accepts RSA 2048 only. An ECDSA CSR, which
17 +//! is what most modern tooling produces by default, is rejected at
18 +//! submission. That single constraint dictates the whole implementation
19 +//! here: [`rcgen`] cannot generate RSA keys, so the key comes from the
20 +//! [`rsa`] crate and is handed to rcgen as an externally supplied key
21 +//! pair purely for the CSR's self-signature.
22 +
23 +use crate::error::{Error, Result};
24 +use rcgen::{CertificateParams, DistinguishedName, DnType, KeyPair as RcgenKeyPair};
25 +use rsa::pkcs8::{EncodePrivateKey, LineEnding};
26 +use rsa::RsaPrivateKey;
27 +use zeroize::Zeroizing;
28 +
29 +/// Apple rejects anything else. Not a tunable.
30 +const RSA_MODULUS_BITS: usize = 2048;
31 +
32 +/// A freshly generated RSA key pair and the CSR that asks Apple to certify
33 +/// it.
34 +///
35 +/// Keep these two together. The CSR is worthless without the key, the
36 +/// certificate Apple returns is worthless without the key, and the key is
37 +/// unrecoverable if dropped.
38 +pub struct CertificateRequest {
39 + /// PEM-encoded PKCS#10, ready for
40 + /// [`CertificateCreateAttributes::csr_content`](crate::certificate::CertificateCreateAttributes::csr_content).
41 + csr_pem: String,
42 + /// PEM-encoded PKCS#8. Wrapped in [`Zeroizing`] so the bytes are
43 + /// wiped when dropped rather than left in freed heap for whatever
44 + /// allocates next.
45 + private_key_pem: Zeroizing<String>,
46 +}
47 +
48 +impl CertificateRequest {
49 + /// The CSR to send Apple, as PEM text including the
50 + /// `-----BEGIN CERTIFICATE REQUEST-----` armour.
51 + pub fn csr_pem(&self) -> &str {
52 + &self.csr_pem
53 + }
54 +
55 + /// The private key, PKCS#8 PEM. This is the secret: write it somewhere
56 + /// encrypted, and never log it, print it to stdout, or return it from
57 + /// an API a language model can read.
58 + pub fn private_key_pem(&self) -> &Zeroizing<String> {
59 + &self.private_key_pem
60 + }
61 +}
62 +
63 +/// Deliberately opaque: printing a key pair, even by accident through a
64 +/// derived `Debug` on some struct three layers up, is how private keys end
65 +/// up in CI logs.
66 +impl std::fmt::Debug for CertificateRequest {
67 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68 + f.debug_struct("CertificateRequest")
69 + .field("csr_pem", &"<csr>")
70 + .field("private_key_pem", &"<redacted>")
71 + .finish()
72 + }
73 +}
74 +
75 +/// Generate an RSA 2048 key pair and a PKCS#10 CSR over it.
76 +///
77 +/// `common_name` becomes the CSR subject's CN. Apple overwrites the
78 +/// subject on the certificate it issues (with `Apple Distribution: Team
79 +/// Name (TEAMID)` or similar), so this value is not what you will see in
80 +/// your keychain afterwards. It is still worth setting to something
81 +/// identifying, because the CSR itself may be archived.
82 +///
83 +/// Generation is CPU-bound and takes a noticeable moment: finding two
84 +/// 1024-bit primes is not free. Call it off the async executor if you are
85 +/// in one.
86 +pub fn generate_certificate_request(common_name: &str) -> Result<CertificateRequest> {
87 + let mut rng = rand::thread_rng();
88 +
89 + let private_key = RsaPrivateKey::new(&mut rng, RSA_MODULUS_BITS)
90 + .map_err(|source| Error::KeyGeneration(source.to_string()))?;
91 +
92 + let private_key_pem = private_key
93 + .to_pkcs8_pem(LineEnding::LF)
94 + .map_err(|source| Error::KeyGeneration(source.to_string()))?;
95 +
96 + // rcgen re-parses the PEM rather than taking the rsa crate's type
97 + // directly: the two crates share no key representation, and PKCS#8 PEM
98 + // is the interchange format both agree on.
99 + let rcgen_key = RcgenKeyPair::from_pem(&private_key_pem)
100 + .map_err(|source| Error::CsrGeneration(source.to_string()))?;
101 +
102 + let mut params = CertificateParams::default();
103 + let mut dn = DistinguishedName::new();
104 + dn.push(DnType::CommonName, common_name);
105 + params.distinguished_name = dn;
106 +
107 + let csr_pem = params
108 + .serialize_request(&rcgen_key)
109 + .map_err(|source| Error::CsrGeneration(source.to_string()))?
110 + .pem()
111 + .map_err(|source| Error::CsrGeneration(source.to_string()))?;
112 +
113 + Ok(CertificateRequest {
114 + csr_pem,
115 + private_key_pem,
116 + })
117 +}
118 +
119 +#[cfg(test)]
120 +mod tests {
121 + use super::*;
122 + use std::io::Write;
123 + use std::process::{Command, Stdio};
124 +
125 + /// Run `openssl` over a PEM on stdin and return stdout **and** stderr
126 + /// joined, or `None` if openssl isn't on this machine.
127 + ///
128 + /// Both streams, deliberately: macOS ships LibreSSL, which prints the
129 + /// `verify OK` banner to stderr while OpenSSL proper puts it on
130 + /// stdout. Reading only one stream makes this test pass or fail based
131 + /// on which implementation the machine happens to have.
132 + fn openssl(args: &[&str], stdin_pem: &str) -> Option<String> {
133 + let mut child = Command::new("openssl")
134 + .args(args)
135 + .stdin(Stdio::piped())
136 + .stdout(Stdio::piped())
137 + .stderr(Stdio::piped())
138 + .spawn()
139 + .ok()?;
140 + child
141 + .stdin
142 + .as_mut()
143 + .expect("stdin piped")
144 + .write_all(stdin_pem.as_bytes())
145 + .expect("writes");
146 + let out = child.wait_with_output().expect("waits");
147 + assert!(
148 + out.status.success(),
149 + "openssl {args:?} failed: {}",
150 + String::from_utf8_lossy(&out.stderr)
151 + );
152 + Some(format!(
153 + "{}{}",
154 + String::from_utf8_lossy(&out.stdout),
155 + String::from_utf8_lossy(&out.stderr)
156 + ))
157 + }
158 +
159 + #[test]
160 + fn csr_is_rsa_2048_and_self_signature_verifies() {
161 + let request = generate_certificate_request("smbCloud signing test").expect("generates");
162 +
163 + // Structural checks that hold with or without openssl present.
164 + assert!(request
165 + .csr_pem()
166 + .starts_with("-----BEGIN CERTIFICATE REQUEST-----"));
167 + assert!(request
168 + .private_key_pem()
169 + .starts_with("-----BEGIN PRIVATE KEY-----"));
170 +
171 + // The parts that actually decide whether Apple accepts this: the
172 + // key must be RSA 2048, and the CSR's own signature must verify.
173 + // Asserting on our own library's output would be circular, so this
174 + // asks a completely independent implementation.
175 + let Some(text) = openssl(&["req", "-noout", "-text", "-verify"], request.csr_pem()) else {
176 + eprintln!("openssl not available; skipped the cryptographic assertions");
177 + return;
178 + };
179 +
180 + assert!(
181 + text.contains("Public-Key: (2048 bit)"),
182 + "expected an RSA 2048 key, got:\n{text}"
183 + );
184 + assert!(
185 + text.contains("rsaEncryption"),
186 + "expected an RSA key; Apple rejects ECDSA CSRs. Got:\n{text}"
187 + );
188 + // openssl prints this only when the CSR's self-signature checks
189 + // out, which is the proof we hold the matching private key.
190 + assert!(
191 + text.contains("Certificate request self-signature verify OK")
192 + || text.contains("verify OK"),
193 + "CSR self-signature did not verify:\n{text}"
194 + );
195 + assert!(
196 + text.contains("smbCloud signing test"),
197 + "common name missing from subject:\n{text}"
198 + );
199 + }
200 +
201 + #[test]
202 + fn each_request_gets_a_fresh_key() {
203 + // Reusing a key across certificates means one compromise revokes
204 + // everything, and it is an easy mistake to make when adding
205 + // caching later.
206 + let a = generate_certificate_request("a").expect("generates");
207 + let b = generate_certificate_request("b").expect("generates");
208 + assert_ne!(
209 + a.private_key_pem().as_str(),
210 + b.private_key_pem().as_str(),
211 + "two requests shared a private key"
212 + );
213 + }
214 +
215 + #[test]
216 + fn debug_never_prints_key_material() {
217 + let request = generate_certificate_request("redaction check").expect("generates");
218 + let rendered = format!("{request:?}");
219 + assert!(rendered.contains("<redacted>"));
220 + assert!(
221 + !rendered.contains("BEGIN PRIVATE KEY"),
222 + "Debug leaked the private key"
223 + );
224 + }
225 +}
crates/ascapi/src/error.rs
+6
@@ -20,6 +20,12 @@ pub enum Error {
20
21 #[error("App Store Connect API returned {status}: {detail}")]
22 Api { status: u16, detail: String },
23 +
24 + #[error("failed to generate an RSA key pair: {0}")]
25 + KeyGeneration(String),
26 +
27 + #[error("failed to build the certificate signing request: {0}")]
28 + CsrGeneration(String),
29 }
30
31 pub type Result<T> = std::result::Result<T, Error>;
crates/cli/Cargo.toml
+1
@@ -13,6 +13,7 @@ path = "src/main.rs"
13
14 [dependencies]
15 anyhow = { workspace = true }
16 +base64 = { workspace = true }
17 clap = { workspace = true, features = ["derive", "env"] }
18 serde = { workspace = true }
19 serde_json = { workspace = true }
crates/cli/src/main.rs
+269
@@ -12,6 +12,8 @@ use smbcloud_ascapi::app_store_version_localization::{
12 AppStoreVersionLocalizationCreateAttributes, AppStoreVersionLocalizationFields,
13 };
14 use smbcloud_ascapi::bundle_id::{BundleIdCreateAttributes, BundleIdPlatform};
15 +use smbcloud_ascapi::certificate::{CertificateCreateAttributes, CertificateType};
16 +use smbcloud_ascapi::csr::generate_certificate_request;
17 use smbcloud_ascapi::{ApiKey, Client};
18 use std::path::PathBuf;
19
@@ -84,6 +86,79 @@ enum Command {
86 #[command(subcommand)]
87 command: AppScreenshotsCommand,
88 },
89 + /// Signing certificates: list what the team holds, issue a new one, or
90 + /// revoke an old one.
91 + Certificates {
92 + #[command(subcommand)]
93 + command: CertificatesCommand,
94 + },
95 +}
96 +
97 +#[derive(Subcommand)]
98 +enum CertificatesCommand {
99 + /// List the team's certificates, newest expiry last. Apple returns
100 + /// expired ones too; `expired` in the output says which.
101 + List {
102 + /// Only show one type.
103 + #[arg(long, value_enum)]
104 + r#type: Option<CliCertificateType>,
105 + },
106 + /// Generate an RSA 2048 key pair, ask Apple to certify it, and write
107 + /// both halves to disk.
108 + ///
109 + /// The private key is written first and is the part that matters:
110 + /// Apple keeps no copy, so losing it makes the certificate permanently
111 + /// useless. Back up the whole output directory somewhere encrypted.
112 + Create {
113 + #[arg(long, value_enum)]
114 + r#type: CliCertificateType,
115 + /// Where to write `<name>.key.pem` and `<name>.cer`.
116 + #[arg(long, default_value = ".")]
117 + out_dir: PathBuf,
118 + /// Subject common name for the CSR. Apple replaces it on the
119 + /// issued certificate, so this only labels the request itself.
120 + #[arg(long, default_value = "smbcloud-ascapi")]
121 + common_name: String,
122 + },
123 + /// Revoke a certificate.
124 + ///
125 + /// Irreversible, and it breaks every provisioning profile embedding
126 + /// this certificate for the whole team at once. Use it to free a slot
127 + /// at Apple's per-type cap, or when a private key is compromised.
128 + Revoke {
129 + id: String,
130 + /// Required. Pass the certificate id again to confirm.
131 + #[arg(long)]
132 + confirm: String,
133 + },
134 +}
135 +
136 +#[derive(Copy, Clone, PartialEq, Eq, ValueEnum)]
137 +enum CliCertificateType {
138 + /// Apple Development — all platforms.
139 + Development,
140 + /// Apple Distribution — App Store and Ad Hoc, all platforms.
141 + Distribution,
142 + /// 3rd Party Mac Developer Application — the .app in a Mac App Store build.
143 + MacAppDistribution,
144 + /// 3rd Party Mac Developer Installer — the .pkg `productbuild --sign` needs.
145 + MacInstallerDistribution,
146 + /// Developer ID Application — distribution outside the App Store.
147 + DeveloperIdApplication,
148 +}
149 +
150 +impl From<CliCertificateType> for CertificateType {
151 + fn from(value: CliCertificateType) -> Self {
152 + match value {
153 + CliCertificateType::Development => CertificateType::Development,
154 + CliCertificateType::Distribution => CertificateType::Distribution,
155 + CliCertificateType::MacAppDistribution => CertificateType::MacAppDistribution,
156 + CliCertificateType::MacInstallerDistribution => {
157 + CertificateType::MacInstallerDistribution
158 + }
159 + CliCertificateType::DeveloperIdApplication => CertificateType::DeveloperIdApplication,
160 + }
161 + }
162 }
163
164 #[derive(Subcommand)]
@@ -396,6 +471,7 @@ async fn main() -> Result<()> {
471 Command::AppScreenshots { command } => {
472 run_app_screenshots(&client, command, cli.dry_run).await
473 }
474 + Command::Certificates { command } => run_certificates(&client, command, cli.dry_run).await,
475 }
476 }
477
@@ -727,3 +803,196 @@ async fn run_app_screenshots(
803 }
804 }
805 }
806 +
807 +/// Human-readable summary of a certificate, with the fields that decide
808 +/// whether it is usable pulled to the top. Deliberately omits
809 +/// `certificateContent`: it is large, and printing it invites pasting
810 +/// certificate bodies into places they should not go.
811 +#[derive(serde::Serialize)]
812 +struct CertificateSummary {
813 + id: String,
814 + name: Option<String>,
815 + r#type: Option<String>,
816 + serial_number: Option<String>,
817 + expiration_date: Option<String>,
818 + /// Computed here rather than by Apple, which offers no such filter.
819 + expired: Option<bool>,
820 +}
821 +
822 +fn summarize(certificate: &smbcloud_ascapi::certificate::Certificate) -> CertificateSummary {
823 + let expiration_date = certificate.attributes.expiration_date.clone();
824 + // Lexicographic comparison is sound for ISO-8601 UTC timestamps, which
825 + // is what Apple returns, and avoids a date-parsing dependency for a
826 + // field that is only ever displayed.
827 + let now = now_iso8601();
828 + let expired = expiration_date
829 + .as_ref()
830 + .map(|at| at.as_str() < now.as_str());
831 +
832 + CertificateSummary {
833 + id: certificate.id.clone(),
834 + name: certificate.attributes.name.clone(),
835 + r#type: certificate
836 + .attributes
837 + .certificate_type
838 + .map(|t| t.as_api_str().to_string()),
839 + serial_number: certificate.attributes.serial_number.clone(),
840 + expiration_date,
841 + expired,
842 + }
843 +}
844 +
845 +/// Current UTC time as `YYYY-MM-DDTHH:MM:SS`, for comparing against
846 +/// Apple's ISO-8601 expiry strings without pulling in a date library.
847 +fn now_iso8601() -> String {
848 + let secs = std::time::SystemTime::now()
849 + .duration_since(std::time::UNIX_EPOCH)
850 + .unwrap_or_default()
851 + .as_secs() as i64;
852 +
853 + // Days since the epoch to a civil date, per Howard Hinnant's
854 + // days_from_civil inverse. Shifting the era to March makes the leap
855 + // day the last day of the year, which removes every special case.
856 + let days = secs.div_euclid(86_400);
857 + let secs_of_day = secs.rem_euclid(86_400);
858 + let z = days + 719_468;
859 + let era = z.div_euclid(146_097);
860 + let doe = z.rem_euclid(146_097);
861 + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
862 + let y = yoe + era * 400;
863 + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
864 + let mp = (5 * doy + 2) / 153;
865 + let d = doy - (153 * mp + 2) / 5 + 1;
866 + let m = if mp < 10 { mp + 3 } else { mp - 9 };
867 + let y = if m <= 2 { y + 1 } else { y };
868 +
869 + format!(
870 + "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}",
871 + y,
872 + m,
873 + d,
874 + secs_of_day / 3600,
875 + (secs_of_day % 3600) / 60,
876 + secs_of_day % 60
877 + )
878 +}
879 +
880 +async fn run_certificates(
881 + client: &Client,
882 + command: CertificatesCommand,
883 + dry_run: bool,
884 +) -> Result<()> {
885 + match command {
886 + CertificatesCommand::List { r#type } => {
887 + let mut certificates = client.list_certificates(r#type.map(Into::into)).await?;
888 + certificates.sort_by(|a, b| {
889 + a.attributes
890 + .expiration_date
891 + .cmp(&b.attributes.expiration_date)
892 + });
893 + let summaries: Vec<_> = certificates.iter().map(summarize).collect();
894 + print_json(&summaries)
895 + }
896 +
897 + CertificatesCommand::Create {
898 + r#type,
899 + out_dir,
900 + common_name,
901 + } => {
902 + let certificate_type: CertificateType = r#type.into();
903 +
904 + if dry_run {
905 + // Stop before generating a key: an unused RSA key pair on
906 + // disk is a small liability, and a dry run should have no
907 + // side effects at all.
908 + println!("# dry run — would generate an RSA 2048 key pair and request:");
909 + print_json(&serde_json::json!({
910 + "certificateType": certificate_type.as_api_str(),
911 + "commonName": common_name,
912 + "outDir": out_dir,
913 + }))?;
914 + return Ok(());
915 + }
916 +
917 + let request = generate_certificate_request(&common_name)
918 + .context("generating the RSA 2048 key pair and CSR")?;
919 +
920 + std::fs::create_dir_all(&out_dir)
921 + .with_context(|| format!("creating {}", out_dir.display()))?;
922 +
923 + // Write the private key BEFORE calling Apple. If the request
924 + // fails we are left with an unused key, which is harmless; the
925 + // other order risks Apple issuing a certificate whose key we
926 + // then fail to persist, which is unrecoverable.
927 + let stem = certificate_type.as_api_str().to_lowercase();
928 + let key_path = out_dir.join(format!("{stem}.key.pem"));
929 + write_private_key(&key_path, request.private_key_pem())
930 + .with_context(|| format!("writing the private key to {}", key_path.display()))?;
931 +
932 + let attributes = CertificateCreateAttributes {
933 + csr_content: request.csr_pem().to_string(),
934 + certificate_type,
935 + };
936 + let certificate = client.create_certificate(attributes).await?;
937 +
938 + let cer_path = out_dir.join(format!("{stem}.cer"));
939 + if let Some(content) = certificate.attributes.certificate_content.as_deref() {
940 + let der =
941 + base64_decode(content).context("decoding the certificate Apple returned")?;
942 + std::fs::write(&cer_path, der)
943 + .with_context(|| format!("writing {}", cer_path.display()))?;
944 + }
945 +
946 + print_json(&serde_json::json!({
947 + "certificate": summarize(&certificate),
948 + "privateKey": key_path,
949 + "certificatePath": cer_path,
950 + "nextSteps": [
951 + format!(
952 + "openssl pkcs12 -export -inkey {} -in {} -out {}.p12",
953 + key_path.display(), cer_path.display(), stem
954 + ),
955 + format!("security import {stem}.p12 -k ~/Library/Keychains/login.keychain-db -T /usr/bin/codesign"),
956 + "security find-identity -v -p codesigning".to_string(),
957 + ],
958 + }))
959 + }
960 +
961 + CertificatesCommand::Revoke { id, confirm } => {
962 + if confirm != id {
963 + anyhow::bail!(
964 + "refusing to revoke: pass --confirm {id} to acknowledge that this breaks \
965 + every provisioning profile embedding this certificate, for the whole team"
966 + );
967 + }
968 + if dry_run {
969 + println!("# dry run — would revoke certificate {id}");
970 + return Ok(());
971 + }
972 + client.revoke_certificate(&id).await?;
973 + print_json(&serde_json::json!({ "revoked": id }))
974 + }
975 + }
976 +}
977 +
978 +/// Write a private key with owner-only permissions, set at creation time
979 +/// rather than chmod'ed afterwards so the key is never briefly readable.
980 +fn write_private_key(path: &std::path::Path, pem: &str) -> std::io::Result<()> {
981 + use std::io::Write;
982 +
983 + let mut options = std::fs::OpenOptions::new();
984 + options.write(true).create(true).truncate(true);
985 + #[cfg(unix)]
986 + {
987 + use std::os::unix::fs::OpenOptionsExt;
988 + options.mode(0o600);
989 + }
990 + let mut file = options.open(path)?;
991 + file.write_all(pem.as_bytes())?;
992 + file.sync_all()
993 +}
994 +
995 +fn base64_decode(input: &str) -> Result<Vec<u8>> {
996 + use base64::Engine;
997 + Ok(base64::engine::general_purpose::STANDARD.decode(input.trim())?)
998 +}