@capgo/cli 8.2.0 → 8.3.0

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.
@@ -1,351 +0,0 @@
1
- // keychain-export.swift
2
- //
3
- // Capgo helper: export ONE iOS signing identity from the user's Keychain as a
4
- // PKCS#12 blob. Always emits a single line of JSON on stdout describing the
5
- // outcome — successful or otherwise — so the Node caller never has to parse
6
- // stderr or guess from exit codes.
7
- //
8
- // Usage:
9
- // keychain-export --sha1 <40-hex-char-cert-sha1>
10
- // --output <path-to-output.p12>
11
- // --passphrase <wrap-passphrase-for-p12>
12
- //
13
- // JSON output (single line on stdout, ALWAYS emitted before exit):
14
- //
15
- // Success:
16
- // {"ok":true,"p12Path":"/tmp/x.p12","p12SizeBytes":4096,"identityName":"Apple Distribution: …"}
17
- //
18
- // Failure:
19
- // {"ok":false,"errorCode":"USER_DENIED","message":"…","osStatus":-128}
20
- // {"ok":false,"errorCode":"NO_IDENTITY","message":"…"}
21
- // {"ok":false,"errorCode":"INVALID_ARGS","message":"…"}
22
- // {"ok":false,"errorCode":"EXPORT_FAILED","message":"…","osStatus":-12345}
23
- // {"ok":false,"errorCode":"WRITE_FAILED","message":"…"}
24
- // {"ok":false,"errorCode":"INTERNAL","message":"…"}
25
- //
26
- // Exit codes (still emitted for shell-style consumers):
27
- // 0 — success
28
- // 1 — generic / internal error
29
- // 2 — argument parsing error (INVALID_ARGS)
30
- // 3 — no identity matching the given SHA1 (NO_IDENTITY)
31
- // 4 — user denied macOS Keychain access (USER_DENIED)
32
- //
33
- // Why we use SecItemExport(.formatPKCS12) and accept the 2 prompts:
34
- // Xcode-imported signing keys are non-extractable (kSecKeyExtractable=false).
35
- // `SecKeyCopyExternalRepresentation` rejects them with
36
- // CSSMERR_CSP_INVALID_KEYATTR_MASK. PKCS#12 wrapped export is the only
37
- // non-GUI path that works on these keys. macOS asks the user twice on first
38
- // run — once for "access" ACL, once for "export" ACL — but caches both
39
- // "Always Allow" decisions, so subsequent runs are silent.
40
- //
41
- // Build:
42
- // swiftc keychain-export.swift -framework Security -o keychain-export
43
- //
44
- // Tested on macOS 11+ (Swift 5.5+, CryptoKit available).
45
-
46
- import CryptoKit
47
- import Foundation
48
- import Security
49
-
50
- // MARK: - Output (always JSON on stdout, always before exit)
51
-
52
- /// JSON-escape a string for embedding in our hand-rolled JSON output. We
53
- /// avoid Foundation's JSONSerialization for output to keep the line shape
54
- /// fully predictable (one line, no spaces, ASCII only when possible).
55
- func jsonEscape(_ s: String) -> String {
56
- var out = ""
57
- out.reserveCapacity(s.count)
58
- for scalar in s.unicodeScalars {
59
- switch scalar {
60
- case "\"": out += "\\\""
61
- case "\\": out += "\\\\"
62
- case "\n": out += "\\n"
63
- case "\r": out += "\\r"
64
- case "\t": out += "\\t"
65
- case "\u{08}": out += "\\b"
66
- case "\u{0C}": out += "\\f"
67
- default:
68
- if scalar.value < 0x20 {
69
- out += String(format: "\\u%04x", scalar.value)
70
- } else {
71
- out.unicodeScalars.append(scalar)
72
- }
73
- }
74
- }
75
- return out
76
- }
77
-
78
- /// Emit a JSON line to stdout and exit. NEVER call exit() any other way.
79
- func emitSuccessAndExit(p12Path: String, p12SizeBytes: Int, identityName: String) -> Never {
80
- let json = "{\"ok\":true,"
81
- + "\"p12Path\":\"\(jsonEscape(p12Path))\","
82
- + "\"p12SizeBytes\":\(p12SizeBytes),"
83
- + "\"identityName\":\"\(jsonEscape(identityName))\""
84
- + "}"
85
- print(json)
86
- exit(0)
87
- }
88
-
89
- func emitFailureAndExit(
90
- code: Int32,
91
- errorCode: String,
92
- message: String,
93
- osStatus: OSStatus? = nil
94
- ) -> Never {
95
- var json = "{\"ok\":false,"
96
- + "\"errorCode\":\"\(jsonEscape(errorCode))\","
97
- + "\"message\":\"\(jsonEscape(message))\""
98
- if let s = osStatus {
99
- json += ",\"osStatus\":\(s)"
100
- }
101
- json += "}"
102
- print(json)
103
- exit(code)
104
- }
105
-
106
- // MARK: - Top-level fatal handler
107
- //
108
- // If anything in main throws, traps, or hits an uncaught issue, we want to at
109
- // least emit a JSON line. Swift doesn't have an easy uncaught-exception hook,
110
- // so the pattern is: wrap all real work in do/catch + use guard everywhere
111
- // instead of force-unwrap. There are still ways to crash Swift (e.g. real
112
- // SIGSEGV from a corrupted heap), but in practice anything reachable from our
113
- // code is recoverable into a JSON failure line.
114
-
115
- enum KeychainExportError: Error {
116
- case invalidArgs(String)
117
- case noIdentity(String)
118
- case userDenied(OSStatus, String)
119
- case exportFailed(OSStatus, String)
120
- case writeFailed(String)
121
- case copyFailed(OSStatus, String)
122
- }
123
-
124
- extension KeychainExportError {
125
- var errorCode: String {
126
- switch self {
127
- case .invalidArgs: return "INVALID_ARGS"
128
- case .noIdentity: return "NO_IDENTITY"
129
- case .userDenied: return "USER_DENIED"
130
- case .exportFailed: return "EXPORT_FAILED"
131
- case .writeFailed: return "WRITE_FAILED"
132
- case .copyFailed: return "EXPORT_FAILED"
133
- }
134
- }
135
- var exitCode: Int32 {
136
- switch self {
137
- case .invalidArgs: return 2
138
- case .noIdentity: return 3
139
- case .userDenied: return 4
140
- default: return 1
141
- }
142
- }
143
- var message: String {
144
- switch self {
145
- case let .invalidArgs(m), let .noIdentity(m), let .writeFailed(m): return m
146
- case let .userDenied(_, m), let .exportFailed(_, m), let .copyFailed(_, m): return m
147
- }
148
- }
149
- var osStatus: OSStatus? {
150
- switch self {
151
- case let .userDenied(s, _), let .exportFailed(s, _), let .copyFailed(s, _): return s
152
- default: return nil
153
- }
154
- }
155
- }
156
-
157
- func emitFailureAndExit(_ error: KeychainExportError) -> Never {
158
- emitFailureAndExit(
159
- code: error.exitCode,
160
- errorCode: error.errorCode,
161
- message: error.message,
162
- osStatus: error.osStatus
163
- )
164
- }
165
-
166
- func describeStatus(_ status: OSStatus) -> String {
167
- let secMessage = SecCopyErrorMessageString(status, nil) as String? ?? "(no description)"
168
- return "\(secMessage) [OSStatus \(status)]"
169
- }
170
-
171
- // MARK: - Args
172
-
173
- struct Args {
174
- var sha1Hex: String = ""
175
- var outputPath: String = ""
176
- var passphrase: String = ""
177
- }
178
-
179
- func parseArgs() throws -> Args {
180
- var args = Args()
181
- let cli = CommandLine.arguments
182
- var i = 1
183
- while i < cli.count {
184
- let flag = cli[i]
185
- i += 1
186
- guard i < cli.count else {
187
- throw KeychainExportError.invalidArgs("Missing value for \(flag)")
188
- }
189
- let value = cli[i]
190
- i += 1
191
- switch flag {
192
- case "--sha1": args.sha1Hex = value.lowercased()
193
- case "--output": args.outputPath = value
194
- case "--passphrase": args.passphrase = value
195
- default: throw KeychainExportError.invalidArgs("Unknown argument: \(flag)")
196
- }
197
- }
198
- if args.sha1Hex.isEmpty {
199
- throw KeychainExportError.invalidArgs("Required: --sha1 <40-hex-char-cert-sha1>")
200
- }
201
- if args.outputPath.isEmpty {
202
- throw KeychainExportError.invalidArgs("Required: --output <path>")
203
- }
204
- if args.passphrase.isEmpty {
205
- throw KeychainExportError.invalidArgs("Required: --passphrase <wrap-passphrase>")
206
- }
207
- if args.sha1Hex.count != 40 || args.sha1Hex.range(of: "^[0-9a-f]{40}$", options: .regularExpression) == nil {
208
- throw KeychainExportError.invalidArgs("--sha1 must be 40 lowercase hex chars (got \"\(args.sha1Hex)\")")
209
- }
210
- return args
211
- }
212
-
213
- // MARK: - SHA1 of cert DER (matches `security find-identity` output)
214
-
215
- func sha1OfCertDer(_ cert: SecCertificate) -> String {
216
- let derData = SecCertificateCopyData(cert) as Data
217
- let hash = Insecure.SHA1.hash(data: derData)
218
- return hash.map { String(format: "%02x", $0) }.joined()
219
- }
220
-
221
- func subjectName(of cert: SecCertificate) -> String {
222
- var commonName: CFString?
223
- let status = SecCertificateCopyCommonName(cert, &commonName)
224
- if status == errSecSuccess, let cn = commonName as String? { return cn }
225
- return SecCertificateCopySubjectSummary(cert) as String? ?? "(unknown)"
226
- }
227
-
228
- // MARK: - Find identity by cert SHA1
229
-
230
- func findIdentityBySha1(_ targetSha1: String) throws -> (SecIdentity, String) {
231
- let query: [String: Any] = [
232
- kSecClass as String: kSecClassIdentity,
233
- kSecReturnRef as String: true,
234
- kSecMatchLimit as String: kSecMatchLimitAll,
235
- ]
236
- var result: CFTypeRef?
237
- let status = SecItemCopyMatching(query as CFDictionary, &result)
238
- if status == errSecItemNotFound {
239
- throw KeychainExportError.noIdentity(
240
- "No identity with cert SHA1 \(targetSha1) found (keychain has no identities at all)."
241
- )
242
- }
243
- if status != errSecSuccess {
244
- throw KeychainExportError.copyFailed(status, "SecItemCopyMatching(identities) failed: \(describeStatus(status))")
245
- }
246
- guard let identities = result as? [SecIdentity] else {
247
- throw KeychainExportError.copyFailed(0, "SecItemCopyMatching returned an unexpected type")
248
- }
249
-
250
- for identity in identities {
251
- var maybeCert: SecCertificate?
252
- let copyStatus = SecIdentityCopyCertificate(identity, &maybeCert)
253
- if copyStatus != errSecSuccess { continue }
254
- guard let cert = maybeCert else { continue }
255
- if sha1OfCertDer(cert) == targetSha1 {
256
- return (identity, subjectName(of: cert))
257
- }
258
- }
259
- throw KeychainExportError.noIdentity(
260
- "No identity with cert SHA1 \(targetSha1) found in any keychain in your default search list."
261
- )
262
- }
263
-
264
- // MARK: - Export to PKCS#12
265
-
266
- func exportIdentityAsPkcs12(_ identity: SecIdentity, passphrase: String) throws -> Data {
267
- // CFString must outlive the SecItemExport call. Holding `cfPass` in a
268
- // local keeps it alive for the duration of this function.
269
- let cfPass: CFString = passphrase as CFString
270
- var keyParams = SecItemImportExportKeyParameters()
271
- keyParams.version = UInt32(SEC_KEY_IMPORT_EXPORT_PARAMS_VERSION)
272
- keyParams.passphrase = Unmanaged.passUnretained(cfPass)
273
-
274
- var exportedData: CFData?
275
- let status = withUnsafePointer(to: &keyParams) { paramsPtr in
276
- SecItemExport(
277
- identity,
278
- .formatPKCS12,
279
- SecItemImportExportFlags(rawValue: 0),
280
- paramsPtr,
281
- &exportedData
282
- )
283
- }
284
-
285
- // Treat user-denied / canceled distinctly so the caller can offer retry
286
- // vs. fall back to a different path. -128 is errSecUserCanceled (raw
287
- // value not always present in Swift's enum on older SDKs, hence direct
288
- // comparison).
289
- if status == errSecAuthFailed || status == errSecUserCanceled || status == -128 {
290
- throw KeychainExportError.userDenied(
291
- status,
292
- "macOS Keychain access was denied by the user. \(describeStatus(status))"
293
- )
294
- }
295
- if status != errSecSuccess {
296
- throw KeychainExportError.exportFailed(
297
- status,
298
- "SecItemExport failed: \(describeStatus(status))"
299
- )
300
- }
301
- guard let data = exportedData else {
302
- throw KeychainExportError.exportFailed(0, "SecItemExport returned nil data with success status")
303
- }
304
-
305
- // Keep cfPass alive past the call — Unmanaged.passUnretained doesn't
306
- // bump the retain count; the Security framework relies on us holding it.
307
- _ = cfPass
308
- return data as Data
309
- }
310
-
311
- // MARK: - Disk write
312
-
313
- func writeP12(_ data: Data, to path: String) throws {
314
- do {
315
- try data.write(to: URL(fileURLWithPath: path), options: .atomic)
316
- } catch {
317
- throw KeychainExportError.writeFailed(
318
- "Failed to write P12 to \(path): \(error.localizedDescription)"
319
- )
320
- }
321
- // Best-effort 0600 chmod. Non-fatal if it fails.
322
- do {
323
- try FileManager.default.setAttributes(
324
- [.posixPermissions: NSNumber(value: Int16(0o600))],
325
- ofItemAtPath: path
326
- )
327
- } catch {
328
- FileHandle.standardError.write(
329
- Data("warning: could not chmod 0600 on \(path): \(error.localizedDescription)\n".utf8)
330
- )
331
- }
332
- }
333
-
334
- // MARK: - Main
335
-
336
- do {
337
- let args = try parseArgs()
338
- let (identity, identityName) = try findIdentityBySha1(args.sha1Hex)
339
- let p12 = try exportIdentityAsPkcs12(identity, passphrase: args.passphrase)
340
- try writeP12(p12, to: args.outputPath)
341
- emitSuccessAndExit(p12Path: args.outputPath, p12SizeBytes: p12.count, identityName: identityName)
342
- } catch let error as KeychainExportError {
343
- emitFailureAndExit(error)
344
- } catch {
345
- // Any other Swift error (Foundation throw, etc.) lands here.
346
- emitFailureAndExit(
347
- code: 1,
348
- errorCode: "INTERNAL",
349
- message: "Unhandled error: \(error.localizedDescription)"
350
- )
351
- }