// keychain-export.swift
//
// Capgo helper: export ONE iOS signing identity from the user's Keychain as a
// PKCS#12 blob. Always emits a single line of JSON on stdout describing the
// outcome — successful or otherwise — so the Node caller never has to parse
// stderr or guess from exit codes.
//
// Usage:
//   keychain-export --sha1 <40-hex-char-cert-sha1>
//                   --output <path-to-output.p12>
//                   --passphrase <wrap-passphrase-for-p12>
//
// JSON output (single line on stdout, ALWAYS emitted before exit):
//
//   Success:
//     {"ok":true,"p12Path":"/tmp/x.p12","p12SizeBytes":4096,"identityName":"Apple Distribution: …"}
//
//   Failure:
//     {"ok":false,"errorCode":"USER_DENIED","message":"…","osStatus":-128}
//     {"ok":false,"errorCode":"NO_IDENTITY","message":"…"}
//     {"ok":false,"errorCode":"INVALID_ARGS","message":"…"}
//     {"ok":false,"errorCode":"EXPORT_FAILED","message":"…","osStatus":-12345}
//     {"ok":false,"errorCode":"WRITE_FAILED","message":"…"}
//     {"ok":false,"errorCode":"INTERNAL","message":"…"}
//
// Exit codes (still emitted for shell-style consumers):
//   0 — success
//   1 — generic / internal error
//   2 — argument parsing error (INVALID_ARGS)
//   3 — no identity matching the given SHA1 (NO_IDENTITY)
//   4 — user denied macOS Keychain access (USER_DENIED)
//
// Why we use SecItemExport(.formatPKCS12) and accept the 2 prompts:
//   Xcode-imported signing keys are non-extractable (kSecKeyExtractable=false).
//   `SecKeyCopyExternalRepresentation` rejects them with
//   CSSMERR_CSP_INVALID_KEYATTR_MASK. PKCS#12 wrapped export is the only
//   non-GUI path that works on these keys. macOS asks the user twice on first
//   run — once for "access" ACL, once for "export" ACL — but caches both
//   "Always Allow" decisions, so subsequent runs are silent.
//
// Build:
//   swiftc keychain-export.swift -framework Security -o keychain-export
//
// Tested on macOS 11+ (Swift 5.5+, CryptoKit available).

import CryptoKit
import Foundation
import Security

// MARK: - Output (always JSON on stdout, always before exit)

/// JSON-escape a string for embedding in our hand-rolled JSON output. We
/// avoid Foundation's JSONSerialization for output to keep the line shape
/// fully predictable (one line, no spaces, ASCII only when possible).
func jsonEscape(_ s: String) -> String {
    var out = ""
    out.reserveCapacity(s.count)
    for scalar in s.unicodeScalars {
        switch scalar {
        case "\"": out += "\\\""
        case "\\": out += "\\\\"
        case "\n": out += "\\n"
        case "\r": out += "\\r"
        case "\t": out += "\\t"
        case "\u{08}": out += "\\b"
        case "\u{0C}": out += "\\f"
        default:
            if scalar.value < 0x20 {
                out += String(format: "\\u%04x", scalar.value)
            } else {
                out.unicodeScalars.append(scalar)
            }
        }
    }
    return out
}

/// Emit a JSON line to stdout and exit. NEVER call exit() any other way.
func emitSuccessAndExit(p12Path: String, p12SizeBytes: Int, identityName: String) -> Never {
    let json = "{\"ok\":true,"
        + "\"p12Path\":\"\(jsonEscape(p12Path))\","
        + "\"p12SizeBytes\":\(p12SizeBytes),"
        + "\"identityName\":\"\(jsonEscape(identityName))\""
        + "}"
    print(json)
    exit(0)
}

func emitFailureAndExit(
    code: Int32,
    errorCode: String,
    message: String,
    osStatus: OSStatus? = nil
) -> Never {
    var json = "{\"ok\":false,"
        + "\"errorCode\":\"\(jsonEscape(errorCode))\","
        + "\"message\":\"\(jsonEscape(message))\""
    if let s = osStatus {
        json += ",\"osStatus\":\(s)"
    }
    json += "}"
    print(json)
    exit(code)
}

// MARK: - Top-level fatal handler
//
// If anything in main throws, traps, or hits an uncaught issue, we want to at
// least emit a JSON line. Swift doesn't have an easy uncaught-exception hook,
// so the pattern is: wrap all real work in do/catch + use guard everywhere
// instead of force-unwrap. There are still ways to crash Swift (e.g. real
// SIGSEGV from a corrupted heap), but in practice anything reachable from our
// code is recoverable into a JSON failure line.

enum KeychainExportError: Error {
    case invalidArgs(String)
    case noIdentity(String)
    case userDenied(OSStatus, String)
    case exportFailed(OSStatus, String)
    case writeFailed(String)
    case copyFailed(OSStatus, String)
}

extension KeychainExportError {
    var errorCode: String {
        switch self {
        case .invalidArgs: return "INVALID_ARGS"
        case .noIdentity: return "NO_IDENTITY"
        case .userDenied: return "USER_DENIED"
        case .exportFailed: return "EXPORT_FAILED"
        case .writeFailed: return "WRITE_FAILED"
        case .copyFailed: return "EXPORT_FAILED"
        }
    }
    var exitCode: Int32 {
        switch self {
        case .invalidArgs: return 2
        case .noIdentity: return 3
        case .userDenied: return 4
        default: return 1
        }
    }
    var message: String {
        switch self {
        case let .invalidArgs(m), let .noIdentity(m), let .writeFailed(m): return m
        case let .userDenied(_, m), let .exportFailed(_, m), let .copyFailed(_, m): return m
        }
    }
    var osStatus: OSStatus? {
        switch self {
        case let .userDenied(s, _), let .exportFailed(s, _), let .copyFailed(s, _): return s
        default: return nil
        }
    }
}

func emitFailureAndExit(_ error: KeychainExportError) -> Never {
    emitFailureAndExit(
        code: error.exitCode,
        errorCode: error.errorCode,
        message: error.message,
        osStatus: error.osStatus
    )
}

func describeStatus(_ status: OSStatus) -> String {
    let secMessage = SecCopyErrorMessageString(status, nil) as String? ?? "(no description)"
    return "\(secMessage) [OSStatus \(status)]"
}

// MARK: - Args

struct Args {
    var sha1Hex: String = ""
    var outputPath: String = ""
    var passphrase: String = ""
}

func parseArgs() throws -> Args {
    var args = Args()
    let cli = CommandLine.arguments
    var i = 1
    while i < cli.count {
        let flag = cli[i]
        i += 1
        guard i < cli.count else {
            throw KeychainExportError.invalidArgs("Missing value for \(flag)")
        }
        let value = cli[i]
        i += 1
        switch flag {
        case "--sha1": args.sha1Hex = value.lowercased()
        case "--output": args.outputPath = value
        case "--passphrase": args.passphrase = value
        default: throw KeychainExportError.invalidArgs("Unknown argument: \(flag)")
        }
    }
    if args.sha1Hex.isEmpty {
        throw KeychainExportError.invalidArgs("Required: --sha1 <40-hex-char-cert-sha1>")
    }
    if args.outputPath.isEmpty {
        throw KeychainExportError.invalidArgs("Required: --output <path>")
    }
    if args.passphrase.isEmpty {
        throw KeychainExportError.invalidArgs("Required: --passphrase <wrap-passphrase>")
    }
    if args.sha1Hex.count != 40 || args.sha1Hex.range(of: "^[0-9a-f]{40}$", options: .regularExpression) == nil {
        throw KeychainExportError.invalidArgs("--sha1 must be 40 lowercase hex chars (got \"\(args.sha1Hex)\")")
    }
    return args
}

// MARK: - SHA1 of cert DER (matches `security find-identity` output)

func sha1OfCertDer(_ cert: SecCertificate) -> String {
    let derData = SecCertificateCopyData(cert) as Data
    let hash = Insecure.SHA1.hash(data: derData)
    return hash.map { String(format: "%02x", $0) }.joined()
}

func subjectName(of cert: SecCertificate) -> String {
    var commonName: CFString?
    let status = SecCertificateCopyCommonName(cert, &commonName)
    if status == errSecSuccess, let cn = commonName as String? { return cn }
    return SecCertificateCopySubjectSummary(cert) as String? ?? "(unknown)"
}

// MARK: - Find identity by cert SHA1

func findIdentityBySha1(_ targetSha1: String) throws -> (SecIdentity, String) {
    let query: [String: Any] = [
        kSecClass as String: kSecClassIdentity,
        kSecReturnRef as String: true,
        kSecMatchLimit as String: kSecMatchLimitAll,
    ]
    var result: CFTypeRef?
    let status = SecItemCopyMatching(query as CFDictionary, &result)
    if status == errSecItemNotFound {
        throw KeychainExportError.noIdentity(
            "No identity with cert SHA1 \(targetSha1) found (keychain has no identities at all)."
        )
    }
    if status != errSecSuccess {
        throw KeychainExportError.copyFailed(status, "SecItemCopyMatching(identities) failed: \(describeStatus(status))")
    }
    guard let identities = result as? [SecIdentity] else {
        throw KeychainExportError.copyFailed(0, "SecItemCopyMatching returned an unexpected type")
    }

    for identity in identities {
        var maybeCert: SecCertificate?
        let copyStatus = SecIdentityCopyCertificate(identity, &maybeCert)
        if copyStatus != errSecSuccess { continue }
        guard let cert = maybeCert else { continue }
        if sha1OfCertDer(cert) == targetSha1 {
            return (identity, subjectName(of: cert))
        }
    }
    throw KeychainExportError.noIdentity(
        "No identity with cert SHA1 \(targetSha1) found in any keychain in your default search list."
    )
}

// MARK: - Export to PKCS#12

func exportIdentityAsPkcs12(_ identity: SecIdentity, passphrase: String) throws -> Data {
    // CFString must outlive the SecItemExport call. Holding `cfPass` in a
    // local keeps it alive for the duration of this function.
    let cfPass: CFString = passphrase as CFString
    var keyParams = SecItemImportExportKeyParameters()
    keyParams.version = UInt32(SEC_KEY_IMPORT_EXPORT_PARAMS_VERSION)
    keyParams.passphrase = Unmanaged.passUnretained(cfPass)

    var exportedData: CFData?
    let status = withUnsafePointer(to: &keyParams) { paramsPtr in
        SecItemExport(
            identity,
            .formatPKCS12,
            SecItemImportExportFlags(rawValue: 0),
            paramsPtr,
            &exportedData
        )
    }

    // Treat user-denied / canceled distinctly so the caller can offer retry
    // vs. fall back to a different path. -128 is errSecUserCanceled (raw
    // value not always present in Swift's enum on older SDKs, hence direct
    // comparison).
    if status == errSecAuthFailed || status == errSecUserCanceled || status == -128 {
        throw KeychainExportError.userDenied(
            status,
            "macOS Keychain access was denied by the user. \(describeStatus(status))"
        )
    }
    if status != errSecSuccess {
        throw KeychainExportError.exportFailed(
            status,
            "SecItemExport failed: \(describeStatus(status))"
        )
    }
    guard let data = exportedData else {
        throw KeychainExportError.exportFailed(0, "SecItemExport returned nil data with success status")
    }

    // Keep cfPass alive past the call — Unmanaged.passUnretained doesn't
    // bump the retain count; the Security framework relies on us holding it.
    _ = cfPass
    return data as Data
}

// MARK: - Disk write

func writeP12(_ data: Data, to path: String) throws {
    do {
        try data.write(to: URL(fileURLWithPath: path), options: .atomic)
    } catch {
        throw KeychainExportError.writeFailed(
            "Failed to write P12 to \(path): \(error.localizedDescription)"
        )
    }
    // Best-effort 0600 chmod. Non-fatal if it fails.
    do {
        try FileManager.default.setAttributes(
            [.posixPermissions: NSNumber(value: Int16(0o600))],
            ofItemAtPath: path
        )
    } catch {
        FileHandle.standardError.write(
            Data("warning: could not chmod 0600 on \(path): \(error.localizedDescription)\n".utf8)
        )
    }
}

// MARK: - Main

do {
    let args = try parseArgs()
    let (identity, identityName) = try findIdentityBySha1(args.sha1Hex)
    let p12 = try exportIdentityAsPkcs12(identity, passphrase: args.passphrase)
    try writeP12(p12, to: args.outputPath)
    emitSuccessAndExit(p12Path: args.outputPath, p12SizeBytes: p12.count, identityName: identityName)
} catch let error as KeychainExportError {
    emitFailureAndExit(error)
} catch {
    // Any other Swift error (Foundation throw, etc.) lands here.
    emitFailureAndExit(
        code: 1,
        errorCode: "INTERNAL",
        message: "Unhandled error: \(error.localizedDescription)"
    )
}
