diff --git a/go.mod b/go.mod index b9e14213..19a3b950 100644 --- a/go.mod +++ b/go.mod @@ -32,6 +32,7 @@ require ( github.com/cenkalti/backoff v2.2.1+incompatible // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/frankban/quicktest v1.14.6 // indirect + github.com/fullsailor/pkcs7 v0.0.0-20190404230743-d7302db945fa // indirect github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect github.com/google/btree v1.1.2 // indirect github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38 // indirect diff --git a/go.sum b/go.sum index bfb6baa0..a464fc8b 100644 --- a/go.sum +++ b/go.sum @@ -13,6 +13,8 @@ github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815 h1:bWDMxwH3px2JBh github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fullsailor/pkcs7 v0.0.0-20190404230743-d7302db945fa h1:RDBNVkRviHZtvDvId8XSGPu3rmpmSe+wKRcEWNgsfWU= +github.com/fullsailor/pkcs7 v0.0.0-20190404230743-d7302db945fa/go.mod h1:KnogPXtdwXqoenmZCw6S+25EAm2MkxbG0deNDu4cbSA= github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ= github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= diff --git a/ios/codesign/codesign.go b/ios/codesign/codesign.go new file mode 100644 index 00000000..82f57e8b --- /dev/null +++ b/ios/codesign/codesign.go @@ -0,0 +1,251 @@ +/* +This package contains everything needed to sign ios apps, parse, validate and generate provisioning profiles and certificates. +*/ +package codesign + +import ( + "errors" + "fmt" + "os" + "os/exec" + "path" + "path/filepath" + "runtime" + "strings" + "time" + + log "github.com/sirupsen/logrus" +) + +const codesignPath = "/usr/bin/codesign" + +// In iOS apps you will find three types of directories that need signing applied. +// They either end with .app, .appex (app extensions) or .xctest. +const ( + appSuffix = ".app" + appExtensionSuffix = ".appex" + xctestSuffix = ".xctest" +) + +// SigningConfig contains the CertSha1 of the certificate that will be used for signing. +// EntitlementsFilePath points to a plist file containing the entitlements extracted from +// the correct mobileprovisioning profile. +// KeychainPath contains the path to the keychain that contains the signing certificate. +type SigningConfig struct { + CertSha1 string + EntitlementsFilePath string + KeychainPath string + ProfileBytes []byte +} + +func Resign(udid string, ipaFile *os.File, s SigningWorkspace) error { + if runtime.GOOS != "darwin" { + return errors.New("Resign: can only resign on macOS for now.") + } + if udid == "" { + return errors.New("udid is empty") + } + info, err := ipaFile.Stat() + if err != nil { + return fmt.Errorf("Resign: could not get file info: %w", err) + } + _, directory, err := ExtractIpa(ipaFile, info.Size()) + if err != nil { + return fmt.Errorf("Resign: could not extract ipa: %w", err) + } + defer os.RemoveAll(directory) + + index := FindProfileForDevice(udid, s.profiles) + + if index == -1 { + return fmt.Errorf("Resign: could not find profile for device %s", udid) + } + + appFolder, err := FindAppFolder(directory) + if err != nil { + return fmt.Errorf("Resign: could not find .app folder in extracted ipa payload folder: %w", err) + } + + archs, err := ExtractArchitectures(appFolder) + if err != nil { + return fmt.Errorf("Resign: could not determine build architecture of build, run 'lipo -info appDir/appExecutable' to debug: %w", err) + } + if IsSimulatorApp(archs) { + return errors.New("Resign: cannot resign simulator app") + } + + err = Sign(directory, s.GetConfig(index)) + if err != nil { + return fmt.Errorf("Resign: could not sign app: %w", err) + } + + w, err := os.Create(ipaFile.Name()) + CompressToIpa(directory, w) + return nil + +} + +// RemoveSignature executes "codesign --remove-signature" for the given path. +func RemoveSignature(dir string) error { + cmd := exec.Command(codesignPath, "--remove-signature", dir) + output, err := cmd.CombinedOutput() + if err != nil { + log.WithFields(log.Fields{"error": err, "cmd": cmd, "output": string(output)}).Errorf("error removing signature with codesign") + return err + } + log.WithFields(log.Fields{"cmd": cmd, "output": string(output)}).Debugf("codesign invoked") + return err +} + +// Sign uses the cert, entitlements and keychain from the SigningConf to codesign the unzipped app +// in the root path. Root needs to be a directory named 'Payload' with all the app contents inside of it. +// Then the filetree will be walked and all the frameworks and app folders will be codesigned. +func Sign(root string, config SigningConfig) error { + if !strings.HasSuffix(root, "Payload") { + root = path.Join(root, "Payload") + } + rootPathInfo, err := os.Stat(root) + if err != nil { + return err + } + if !rootPathInfo.IsDir() { + return errors.New("does not exist:" + root) + } + dirs, err := findAppDirs(root) + if err != nil { + return err + } + + for _, dir := range dirs { + err := signFrameworks(dir, config) + if err != nil { + return fmt.Errorf("error signing frameworks %s err:%w", dir, err) + } + err = signAppDir(dir, config) + if err != nil { + return fmt.Errorf("error signing appDir %s err:%w", dir, err) + } + } + + return nil +} + +// Verify runs "codesign -vv --deep" to verbosely verify recursively the given path is properly signed. +func Verify(path string) error { + cmd := exec.Command(codesignPath, "-vv", "--deep", path) + output, err := cmd.CombinedOutput() + if err != nil { + log.WithFields(log.Fields{"path": path, "error": err, "cmd": cmd, "output": string(output)}).Infof("codesign invoked") + return err + } + return nil +} + +func signFrameworks(root string, config SigningConfig) error { + frameworksPath := path.Join(root, "Frameworks") + //it is a recursive call, if there are no more frameworks found, we just return nil here + if _, err := os.Stat(frameworksPath); os.IsNotExist(err) { + return nil + } + files, err := os.ReadDir(frameworksPath) + if err != nil { + return err + } + //Now recursively look into each child to find other Frameworks directories deeper + //in the file tree and sign them. To get valid overall signatures, of course the + //Frameworks at the leaf level of the file tree must be signed first. + // Afterwards sign the current Frameworks directory. + for _, file := range files { + if strings.HasSuffix(file.Name(), ".framework") { + fullpath := path.Join(frameworksPath, file.Name()) + err := signFrameworks(fullpath, config) + if err != nil { + return fmt.Errorf("signing Frameworks had err:%w", err) + } + err = exeuteCodesignFramework(fullpath, config) + if err != nil { + return fmt.Errorf("running codesign on frameworks had err:%w", err) + } + } + } + return nil +} + +func exeuteCodesignFramework(path string, config SigningConfig) error { + cmd := exec.Command(codesignPath, "-vv", "--keychain", config.KeychainPath, "--deep", "--force", "--sign", config.CertSha1, path) + output, err := cmd.CombinedOutput() + if err != nil { + log.WithFields(log.Fields{"error": err, "cmd": cmd, "output": string(output)}).Errorf("codesign invoked") + return err + } + log.WithFields(log.Fields{"cmd": cmd, "output": string(output)}).Debugf("codesign invoked") + return err +} + +func signAppDir(appPath string, config SigningConfig) error { + if shouldReplaceProfile(appPath) { + target := path.Join(appPath, "embedded.mobileprovision") + err := os.WriteFile(target, config.ProfileBytes, 0644) + if err != nil { + return fmt.Errorf("failed replacing embedded.mobileprovision profile in %s with %w", appPath, err) + } + } + cmd := exec.Command(codesignPath, "-vv", "--keychain", config.KeychainPath, "--deep", "--force", "--sign", config.CertSha1, "--entitlements", config.EntitlementsFilePath, appPath) + output, err := cmd.CombinedOutput() + + if err != nil { + log.WithFields(log.Fields{"error": err, "cmd": cmd, "output": string(output)}).Errorf("codesign failed") + return err + } + log.WithFields(log.Fields{"cmd": cmd, "output": string(output)}).Debugf("codesign invoked") + return err +} + +func findAppDirs(root string) ([]string, error) { + allFiles, err := GetFiles(root) + if err != nil { + return []string{}, err + } + appDirs := []string{} + for _, file := range allFiles { + if isDirWithApp(file) { + appDirs = append(appDirs, file) + } + } + return reverse(appDirs), nil +} + +func isDirWithApp(dir string) bool { + return strings.HasSuffix(dir, appSuffix) || strings.HasSuffix(dir, xctestSuffix) || strings.HasSuffix(dir, appExtensionSuffix) +} + +func shouldReplaceProfile(dir string) bool { + return strings.HasSuffix(dir, appSuffix) || strings.HasSuffix(dir, appExtensionSuffix) +} + +// GetFiles performs a walk to recursively find all files and directories in the given root path. +// It returns a list of all files omitting the root path itself. +func GetFiles(root string) ([]string, error) { + walkStart := time.Now() + var files []string + err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil { + return fmt.Errorf("error walking file tree for path: %s error: %w", path, err) + } + if path == root { + return nil + } + files = append(files, path) + return nil + }) + walkDuration := time.Since(walkStart) + log.Infof("Walk duration: %v", walkDuration) + return files, err +} + +func reverse(a []string) []string { + for left, right := 0, len(a)-1; left < right; left, right = left+1, right-1 { + a[left], a[right] = a[right], a[left] + } + return a +} diff --git a/ios/codesign/codesign_test.go b/ios/codesign/codesign_test.go new file mode 100644 index 00000000..624e3fe9 --- /dev/null +++ b/ios/codesign/codesign_test.go @@ -0,0 +1,129 @@ +package codesign_test + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "path" + "testing" + "time" + + "github.com/danielpaulus/go-ios/ios/codesign" + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" +) + +// TestCodeSign tests the resigning process end to end. +// The ipa will be extracted, signed, zipped and in case +// the environment variable udid is specified, installed to a device. +func TestCodeSign(t *testing.T) { + + ipa := readBytes("fixtures/wda.ipa") + + workspace, cleanup, err := makeWorkspace() + if err != nil { + log.Errorf("failed creating workspace: %+v", err) + t.Fail() + return + } + defer cleanup() + + readerAt := bytes.NewReader(ipa) + duration, directory, err := codesign.ExtractIpa(readerAt, int64(len(ipa))) + if err != nil { + log.Errorf("failed extracting: %+v", err) + t.Fail() + return + } + log.Infof("Extraction took:%v", duration) + defer os.RemoveAll(directory) + + index := 0 + if udid, yes := runOnRealDevice(); yes { + index, err = findProfile(udid) + if err != nil { + log.Errorf("failed finding profile: %+v", err) + t.Fail() + return + } + } + signingConfig := workspace.GetConfig(index) + + startSigning := time.Now() + err = codesign.Sign(directory, signingConfig) + assert.NoError(t, err) + durationSigning := time.Since(startSigning) + log.Infof("signing took: %v", durationSigning) + + b := &bytes.Buffer{} + + assert.NoError(t, codesign.Verify(path.Join(directory, "Payload", "WebDriverAgentRunner-Runner.app"))) + + compressStart := time.Now() + err = codesign.CompressToIpa(directory, b) + if err != nil { + log.Errorf("Compression failed with %+v", err) + t.Fail() + return + } + compressDuration := time.Since(compressStart) + log.Infof("compressiontook: %v", compressDuration) + + if udid, yes := runOnRealDevice(); yes { + installOnRealDevice(udid, b.Bytes()) + } else { + log.Warn("No UDID provided, not running installation on actual device") + } +} + +func runOnRealDevice() (string, bool) { + udid := os.Getenv("udid") + return udid, udid != "" +} + +func makeWorkspace() (codesign.SigningWorkspace, func(), error) { + dir, err := os.MkdirTemp("", "sign-test") + if err != nil { + return codesign.SigningWorkspace{}, nil, err + } + + workspace := codesign.NewSigningWorkspace(dir) + workspace.PrepareProfiles("../provisioningprofiles") + workspace.PrepareKeychain("test.keychain") + + cleanUp := func() { + defer os.RemoveAll(dir) + defer workspace.Close() + } + return workspace, cleanUp, nil +} + +func findProfile(udid string) (int, error) { + profiles, err := codesign.ParseProfiles("../provisioningprofiles") + if err != nil { + return -1, fmt.Errorf("could not parse profiles %+v", err) + } + index := codesign.FindProfileForDevice(udid, profiles) + if index == -1 { + return -1, fmt.Errorf("Device: %s is not in profiles", udid) + } + return index, nil +} + +func installOnRealDevice(udid string, ipa []byte) { + ipafile, err := os.CreateTemp("", "myname-*.ipa") + if err != nil { + log.Error(err) + } + defer os.Remove(ipafile.Name()) + + ipafile.Write(ipa) + ipafile.Close() + + installerlogs, err := exec.Command("ios", "install", ipafile.Name(), "--udid="+udid).CombinedOutput() + if err != nil { + log.Errorf("failed installing, logs: %s with err %+v", string(installerlogs), err) + } + log.Info("Install successful") +} diff --git a/ios/codesign/fixtures/wda.ipa b/ios/codesign/fixtures/wda.ipa new file mode 100644 index 00000000..9fd732f0 Binary files /dev/null and b/ios/codesign/fixtures/wda.ipa differ diff --git a/ios/codesign/infoplistparser.go b/ios/codesign/infoplistparser.go new file mode 100644 index 00000000..faedc519 --- /dev/null +++ b/ios/codesign/infoplistparser.go @@ -0,0 +1,29 @@ +package codesign + +import ( + "fmt" + "os" + "path" + + "howett.net/plist" +) + +const bundleIdentifierKey = "CFBundleIdentifier" +const infoPlist = "Info.plist" + +// GetBundleIdentifier takes a directory where it can find a Info.plist, reads the info.plist and will return the Bundle Identifer of the app +func GetBundleIdentifier(binDir string) (string, error) { + plistBytes, err := os.ReadFile(path.Join(binDir, infoPlist)) + if err != nil { + return "", err + } + var data map[string]interface{} + _, err = plist.Unmarshal(plistBytes, &data) + if err != nil { + return "", err + } + if val, ok := data[bundleIdentifierKey]; ok { + return val.(string), nil + } + return "", fmt.Errorf("%s not in Info.plist: %+v", bundleIdentifierKey, data) +} diff --git a/ios/codesign/infoplistparser_test.go b/ios/codesign/infoplistparser_test.go new file mode 100644 index 00000000..3172d9bd --- /dev/null +++ b/ios/codesign/infoplistparser_test.go @@ -0,0 +1,29 @@ +package codesign_test + +import ( + "bytes" + "os" + "testing" + + "github.com/danielpaulus/go-ios/ios/codesign" + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" +) + +func TestIpaFile(t *testing.T) { + ipa := readBytes("fixtures/wda.ipa") + + readerAt := bytes.NewReader(ipa) + _, directory, err := codesign.ExtractIpa(readerAt, int64(len(ipa))) + if err != nil { + log.Fatalf("failed extracting: %+v", err) + } + defer os.RemoveAll(directory) + appdir, _ := codesign.FindAppFolder(directory) + var expectedBundleId = "com.facebook.WebDriverAgentRunner.xctrunner" + + extractedBundleId, err := codesign.GetBundleIdentifier(appdir) + + assert.NoError(t, err) + assert.Equal(t, expectedBundleId, extractedBundleId) +} diff --git a/ios/codesign/ipautils.go b/ios/codesign/ipautils.go new file mode 100644 index 00000000..86f05a07 --- /dev/null +++ b/ios/codesign/ipautils.go @@ -0,0 +1,54 @@ +package codesign + +import ( + "fmt" + "os" + "path" + "path/filepath" +) + +// EmbeddedProfileName contains the default name for the +// embedded.mobileprovision profile in all developer apps +const EmbeddedProfileName = "embedded.mobileprovision" + +// ContainsAppstoreBuild for a given root dir, +// returns true if /Payload/*.app/embedded.mobileprovision exists and false otherwise. +func ContainsAppstoreBuild(root string) bool { + appFolder, err := FindAppFolder(root) + if err != nil { + return false + } + embeddedProfile := path.Join(appFolder, EmbeddedProfileName) + _, err = os.Stat(embeddedProfile) + if err != nil { + return true + } + return false +} + +// FindAppFolder returns the path of the /Payload/*.app directory +// or an error if there is no .app directory or more than one. +func FindAppFolder(rootDir string) (string, error) { + appFolders, err := filepath.Glob(path.Join(rootDir, "Payload", "*.app")) + if err != nil { + return "", err + } + if len(appFolders) != 1 { + return "", fmt.Errorf("found more or less than exactly one app folder: %+v", appFolders) + } + return appFolders[0], nil +} + +// FindAppFolderVirtualDevice returns the path of the *.app directory +// which must be in the root of the unzipped file. +// or an error if there is no .app directory or more than one. +func FindAppFolderVirtualDevice(rootDir string) (string, error) { + appFolders, err := filepath.Glob(path.Join(rootDir, "*.app")) + if err != nil { + return "", err + } + if len(appFolders) != 1 { + return "", fmt.Errorf("found more or less than exactly one app folder: %+v", appFolders) + } + return appFolders[0], nil +} diff --git a/ios/codesign/ipautils_test.go b/ios/codesign/ipautils_test.go new file mode 100644 index 00000000..fb8dbfa8 --- /dev/null +++ b/ios/codesign/ipautils_test.go @@ -0,0 +1,62 @@ +package codesign_test + +import ( + "io/ioutil" + "os" + "path" + "testing" + + "github.com/danielpaulus/go-ios/ios/codesign" + "github.com/stretchr/testify/assert" +) + +func TestFindAppfolder(t *testing.T) { + tempdir, appPath, notAnAppPath, appStoreAppPath, err := setUpExampleAppDir() + defer os.RemoveAll(tempdir) + foundAppPath, err := codesign.FindAppFolder(appPath) + if assert.NoError(t, err) { + assert.Contains(t, foundAppPath, tempdir) + } + + foundAppPath, err = codesign.FindAppFolderVirtualDevice(path.Join(appPath, "Payload")) + if assert.NoError(t, err) { + assert.Contains(t, foundAppPath, tempdir) + } + + _, err = codesign.FindAppFolder(notAnAppPath) + assert.Error(t, err) + + assert.False(t, codesign.ContainsAppstoreBuild(appPath)) + assert.False(t, codesign.ContainsAppstoreBuild(notAnAppPath)) + assert.True(t, codesign.ContainsAppstoreBuild(appStoreAppPath)) + +} + +func setUpExampleAppDir() (string, string, string, string, error) { + tempdir, err := os.MkdirTemp("", "goios-findappfolder-test") + if err != nil { + return "", "", "", "", err + } + appPath := path.Join(tempdir, "app") + err = os.MkdirAll(path.Join(appPath, "Payload", "test.app"), 0777) + if err != nil { + return "", "", "", "", err + } + notAnAppPath := path.Join(tempdir, "no-app") + err = os.MkdirAll(path.Join(notAnAppPath, "ayload", "test.app"), 0777) + if err != nil { + return "", "", "", "", err + } + appStoreAppPath := path.Join(tempdir, "appstore") + err = os.MkdirAll(path.Join(appStoreAppPath, "Payload", "test.app"), 0777) + if err != nil { + return "", "", "", "", err + } + + err = ioutil.WriteFile(path.Join(appPath, "Payload", "test.app", codesign.EmbeddedProfileName), []byte("example file"), 777) + if err != nil { + return "", "", "", "", err + } + + return tempdir, appPath, notAnAppPath, appStoreAppPath, nil +} diff --git a/ios/codesign/lipo.go b/ios/codesign/lipo.go new file mode 100644 index 00000000..047a36f4 --- /dev/null +++ b/ios/codesign/lipo.go @@ -0,0 +1,80 @@ +package codesign + +import ( + "fmt" + "io/ioutil" + "os/exec" + "path" + "strings" + + "howett.net/plist" +) + +const executableKey = "CFBundleExecutable" + +const lipo = "/usr/bin/lipo" + +// CheckLipo check if lipo works properly +func CheckLipo() error { + cmd := exec.Command(lipo, "-info", lipo) + _, err := cmd.CombinedOutput() + return err +} + +// IsSimulatorApp returns true if one of the architectures equals "x86_64". +// It returns false otherwise. +func IsSimulatorApp(architectures []string) bool { + for _, arch := range architectures { + if arch == "x86_64" { + return true + } + } + return false +} + +// ExtractArchitectures takes a directory where it can find a Info.plist and an executable file to check. +// Usually that will be the .app folder. +// It will parse the Info.plist to find the executable file and run lipo -info against it to extract +// architectures. +// It returns an string array with the parsed architectures contained. +func ExtractArchitectures(binDir string) ([]string, error) { + binFile, err := getExecutable(binDir) + if err != nil { + return []string{}, err + } + cmd := exec.Command(lipo, "-info", path.Join(binDir, binFile)) + output, err := cmd.CombinedOutput() + architectures := strings.TrimSpace(string(output)) + + if strings.Contains(architectures, "is architecture: ") { + splitted := strings.Split(architectures, "is architecture: ") + if len(splitted) != 2 { + return []string{}, fmt.Errorf("architectures could not be found in lipo output: %s", architectures) + } + return []string{splitted[1]}, nil + } + + splitted := strings.Split(architectures, "are: ") + if len(splitted) != 2 { + return []string{}, fmt.Errorf("architectures could not be found in lipo output: %s", architectures) + } + architectures = splitted[1] + + return strings.Split(architectures, " "), err +} + +func getExecutable(binDir string) (string, error) { + plistBytes, err := ioutil.ReadFile(path.Join(binDir, infoPlist)) + if err != nil { + return "", err + } + var data map[string]interface{} + _, err = plist.Unmarshal(plistBytes, &data) + if err != nil { + return "", err + } + if val, ok := data[executableKey]; ok { + return val.(string), nil + } + return "", fmt.Errorf("%s not in Info.plist: %+v", executableKey, data) +} diff --git a/ios/codesign/lipo_test.go b/ios/codesign/lipo_test.go new file mode 100644 index 00000000..6f6462e5 --- /dev/null +++ b/ios/codesign/lipo_test.go @@ -0,0 +1,47 @@ +package codesign_test + +import ( + "bytes" + "io/ioutil" + "os" + "testing" + + "github.com/danielpaulus/go-ios/ios/codesign" + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" +) + +func TestPhysicalDevice(t *testing.T) { + ipa := readBytes("../codesign/fixtures/wda.ipa") + + readerAt := bytes.NewReader(ipa) + _, directory, err := codesign.ExtractIpa(readerAt, int64(len(ipa))) + if err != nil { + log.Fatalf("failed extracting: %+v", err) + } + defer os.RemoveAll(directory) + appdir, _ := codesign.FindAppFolder(directory) + expectedArchs := []string{"armv7", "armv7s", "arm64"} + + extractedArchs, err := codesign.ExtractArchitectures(appdir) + + assert.NoError(t, err) + assert.ElementsMatch(t, expectedArchs, extractedArchs) + assert.False(t, codesign.IsSimulatorApp(extractedArchs)) +} + +func TestLipoCheck(t *testing.T) { + assert.NoError(t, codesign.CheckLipo()) +} + +func readBytes(name string) []byte { + file, err := os.Open(name) + if err != nil { + log.Fatal(err) + } + data, err := ioutil.ReadAll(file) + if err != nil { + log.Fatal(err) + } + return data +} diff --git a/ios/codesign/profileparser.go b/ios/codesign/profileparser.go new file mode 100644 index 00000000..201c890b --- /dev/null +++ b/ios/codesign/profileparser.go @@ -0,0 +1,185 @@ +package codesign + +import ( + "bytes" + "crypto/sha1" + "crypto/x509" + "fmt" + "io/ioutil" + "os" + "path" + "path/filepath" + "strings" + "time" + + log "github.com/sirupsen/logrus" + "golang.org/x/crypto/pkcs12" + + "github.com/fullsailor/pkcs7" + plist "howett.net/plist" +) + +// ProfileAndCertificate contains a profiles raw bytes, +// a parsed MobileProvisioningProfile struct to access the fields, +// the p12 sha1 fingerpringt, x509.Certificate and the raw p12 bytes +// belonging to this profile. +type ProfileAndCertificate struct { + RawData []byte + MobileProvisioningProfile MobileProvisioningProfile + CertificateSha1 string + SigningCert *x509.Certificate + P12Bytes []byte +} + +// MobileProvisioningProfile is an exact representation of a *.mobileprovision plist +type MobileProvisioningProfile struct { + AppIDName string + ApplicationIdentifierPrefix []string + CreationDate time.Time + Platform []string + IsXcodeManaged bool + DeveloperCertificates [][]byte + Entitlements map[string]interface{} + ExpirationDate time.Time + Name string + ProvisionedDevices []string + TeamIdentifier []string + TeamName string + TimeToLive int + UUID string + Version int +} + +const P12Password = "a" + +// FindProfileForDevice finds the correct profile for a given device udid out of an array +// of profiles and returns the index of the correct profile or -1 if the device is not in any of them +func FindProfileForDevice(udid string, profileAndCertificates []ProfileAndCertificate) int { + for profileIndex, profileAndCertificate := range profileAndCertificates { + for _, profileUdid := range profileAndCertificate.MobileProvisioningProfile.ProvisionedDevices { + if profileUdid == udid { + return profileIndex + } + } + } + return -1 +} + +func verifyP12CertIsInProfile(p12cert *x509.Certificate, certificates []*x509.Certificate) bool { + if len(certificates) == 0 { + return false + } + p12certHash := getSha1Fingerprint(p12cert) + for _, cert := range certificates { + if p12certHash == getSha1Fingerprint(cert) { + return true + } + } + return false +} + +// IsEnterpriseProfile returns true if there is an enterprise profile at profilePath and +// false otherwise. +func IsEnterpriseProfile(profilePath string) bool { + profileBytes, err := os.ReadFile(profilePath) + if err != nil { + return false + } + p7, err := pkcs7.Parse(profileBytes) + if err != nil { + return false + } + + decoder := plist.NewDecoder(bytes.NewReader(p7.Content)) + + var profile map[string]interface{} + err = decoder.Decode(&profile) + if err != nil { + return false + } + if val, ok := profile["ProvisionsAllDevices"]; ok { + return val.(bool) + } + return false +} + +// ParseProfiles looks for *.mobileprovision in the given path and parses each of them. +// It returns an error if the path does not contain any profiles. +func ParseProfiles(profilesPath string) ([]ProfileAndCertificate, error) { + result := []ProfileAndCertificate{} + profiles, err := filepath.Glob(path.Join(profilesPath, "*.mobileprovision")) + if err != nil { + return result, err + } + for _, file := range profiles { + log.Infof("parsing profile '%s'", file) + profile, err := ParseProfile(file) + if err != nil { + return result, err + } + result = append(result, profile) + + } + if len(result) == 0 { + return result, fmt.Errorf("no profiles found in path %s", profilesPath) + } + return result, nil +} + +// ParseProfile extracts the plist from a pkcs7 signed mobileprovision file. +// It decodes the plist into a go struct. Additionally a p12 certificate +// must be present next to the profile with the same filename. +// Example: test.mobileprovision and test.p12 must both be present or the parser will fail. +// The parser also checks if the p12 certificate is contained in the profile to prevent errors. +// It returns a ProfileAndCertificate struct containing everything needed for signing. +func ParseProfile(profilePath string) (ProfileAndCertificate, error) { + profileBytes, err := ioutil.ReadFile(profilePath) + if err != nil { + return ProfileAndCertificate{}, err + } + p12bytes, err := os.ReadFile(strings.Replace(profilePath, ".mobileprovision", ".p12", 1)) + if err != nil { + return ProfileAndCertificate{}, fmt.Errorf("Failed reading p12 file for %s with err: %+v", profilePath, err) + } + + _, cert, err := pkcs12.Decode(p12bytes, P12Password) + if err != nil { + return ProfileAndCertificate{}, fmt.Errorf("Failed parsing p12 certificate with: %+v", err) + } + + p7, err := pkcs7.Parse(profileBytes) + if err != nil { + return ProfileAndCertificate{}, err + } + + decoder := plist.NewDecoder(bytes.NewReader(p7.Content)) + + var profile MobileProvisioningProfile + err = decoder.Decode(&profile) + + parsedDeveloperCertificates := make([]*x509.Certificate, len(profile.DeveloperCertificates)) + + for i, certBytes := range profile.DeveloperCertificates { + cert, err := x509.ParseCertificate(certBytes) + parsedDeveloperCertificates[i] = cert + if err != nil { + return ProfileAndCertificate{}, err + } + } + + if !verifyP12CertIsInProfile(cert, parsedDeveloperCertificates) { + return ProfileAndCertificate{}, fmt.Errorf("p12 certificate is not contained in provisioning profile, wrong profile file for this p12") + } + + return ProfileAndCertificate{MobileProvisioningProfile: profile, + RawData: profileBytes, + CertificateSha1: getSha1Fingerprint(cert), + P12Bytes: p12bytes, + SigningCert: cert, + }, err +} + +func getSha1Fingerprint(cert *x509.Certificate) string { + fp := sha1.Sum(cert.Raw) + return fmt.Sprintf("%x", fp) +} diff --git a/ios/codesign/profileparser_test.go b/ios/codesign/profileparser_test.go new file mode 100644 index 00000000..e2ef976e --- /dev/null +++ b/ios/codesign/profileparser_test.go @@ -0,0 +1,57 @@ +package codesign_test + +import ( + "log" + "testing" + "time" + + "github.com/danielpaulus/go-ios/ios/codesign" + "github.com/stretchr/testify/assert" +) + +func TestDirWithoutProfiles(t *testing.T) { + _, err := codesign.ParseProfiles(".") + assert.Error(t, err) +} + +func TestP12NotInProfile(t *testing.T) { + _, err := codesign.ParseProfiles("fixtures/profile_notmatching_cert") + assert.Equal(t, "p12 certificate is not contained in provisioning profile, wrong profile file for this p12", err.Error()) +} + +func TestEnterpriseProfileDetection(t *testing.T) { + shouldBeTrue := codesign.IsEnterpriseProfile("fixtures/enterpriseprofile/embedded.mobileprovision") + assert.True(t, shouldBeTrue) + shouldBeFalse := codesign.IsEnterpriseProfile("fixtures/test.mobileprovision") + assert.False(t, shouldBeFalse) +} + +func TestParsing(t *testing.T) { + profileAndCertificates, err := codesign.ParseProfiles("fixtures") + if err != nil { + log.Fatalf("failed finding profiles %+v", err) + } + assert.Equal(t, 2, len(profileAndCertificates)) + + profileAndCertificate := profileAndCertificates[1] + profile := profileAndCertificate.MobileProvisioningProfile + if assert.NoError(t, err) { + + assert.Equal(t, time.Date(2021, 10, 21, 06, 52, 23, 0, time.UTC), profile.ExpirationDate) + } + +} + +func TestFindDeviceInProfile(t *testing.T) { + profileAndCertificates, err := codesign.ParseProfiles("fixtures") + if err != nil { + log.Fatalf("failed finding profiles %+v", err) + } + for profileIndex, profileAndCertificate := range profileAndCertificates { + for _, udid := range profileAndCertificate.MobileProvisioningProfile.ProvisionedDevices { + foundIndex := codesign.FindProfileForDevice(udid, profileAndCertificates) + assert.Equal(t, profileIndex, foundIndex) + } + } + assert.Equal(t, -1, codesign.FindProfileForDevice("not contained", profileAndCertificates)) +} diff --git a/ios/codesign/security.go b/ios/codesign/security.go new file mode 100644 index 00000000..4c13a452 --- /dev/null +++ b/ios/codesign/security.go @@ -0,0 +1,138 @@ +package codesign + +import ( + "os/exec" + "strings" + + log "github.com/sirupsen/logrus" +) + +const securityPath = "/usr/bin/security" + +//KeychainPassword contains some password for the keychain created and unlocked. +//The password can be publicly visible because the keychain created here +//only will contain our signing +//certificate, which is included in github and in the filesystem anyway. +//The certificate is even contained in the installed application, so anyone +//with access to our devices(all customers) could potentially extract it. +const KeychainPassword = "keychain-pwd" + +//CreateKeychain creates a new keychain file at the specified path by invoking +//the "security create-keychain" command. +func CreateKeychain(path string) error { + _, err := executeSecurity("create-keychain", "-p", KeychainPassword, path) + return err +} + +//AddKeychainToSearchList adds a new keychain path to the current +//search list by getting all entries first, adding the given path and then +//setting the new list. This has a slight probability of a race condition, so use with care +//and never invoke this concurrently!. +func AddKeychainToSearchList(path string) error { + keychainSearchList, err := GetKeychainSearchList() + if err != nil { + return err + } + keychainSearchList = append(keychainSearchList, path) + return SetKeychainSearchList(keychainSearchList) +} + +//RemoveFromKeychainSearchList remove an entry from the keychainSearchList only if it is present. +//If the element is not in the list, nothing will happen +func RemoveFromKeychainSearchList(path string) error { + keychainSearchList, err := GetKeychainSearchList() + if err != nil { + return err + } + index := -1 + for i, entry := range keychainSearchList { + //for some paths, the security command changes directory automatically + //f.ex. temp dirs will be changed from /var/.. to /private/var which causes removing to become a non op + if strings.Contains(entry, path) { + index = i + } + } + if index != -1 { + newKeychainList := append(keychainSearchList[:index], keychainSearchList[index+1:]...) + return SetKeychainSearchList(newKeychainList) + } + log.Warn("tried to remove a non existing keychain, could be a bug") + return nil + +} + +//SetKeychainSearchList sets the current keychain search list using the +//security list-kechain -s [keychain1] [keychain2]... command +// (!!)Be careful to only use this in combination with GetKeychainSearchList as it +//completely replaces the current list instead of just adding a new entry. +func SetKeychainSearchList(entries []string) error { + cmd := []string{"list-keychain", "-s"} + + cmd = append(cmd, entries...) + output, err := executeSecurity(cmd...) + log.Debug(output) + return err +} + +//GetKeychainSearchList parses the output of security list-keychain to a +//string slice containing all the entries +func GetKeychainSearchList() ([]string, error) { + output, err := executeSecurity("list-keychain") + if err != nil { + return []string{}, err + } + output = strings.ReplaceAll(output, "\"", "") + list := strings.Split(output, "\n") + length := len(list) + if list[length-1] == "" { + list = list[:length-1] + } + for i, s := range list { + list[i] = strings.TrimSpace(s) + } + return list, nil +} + +//UnlockKeychain unlocks the keychain so we can use it for signing and installing a certificate. +//Don't forget to disable the timeout too so it does not lock itself again. +func UnlockKeychain(path string) error { + _, err := executeSecurity("unlock-keychain", "-p", KeychainPassword, path) + return err +} + +//DisableTimeoutForKeychain sets the timeout to no timeout by invoking "security set-keychain-settings". +// "security set-keychain-settings -h" explains why this works if you do not specify a '-t' switch like so: +// -t Timeout in seconds (omitting this option specifies "no timeout") +func DisableTimeoutForKeychain(path string) error { + _, err := executeSecurity("set-keychain-settings", path) + return err +} + +//AddX509CertificateToKeychain installs a x509 based certificate extracted directly from a +//MobileProvisioningProfile into the given keychain. +func AddX509CertificateToKeychain(keychain string, certificate string) error { + _, err := executeSecurity("import", certificate, "-k", keychain, "-P", P12Password, "-T", codesignPath) + return err +} + +//KeychainHasCertificate looks for the sha1hash to be present in the given keychain. +//It uses "security find-certificate -Z keychainpath" which prints cert output and SHA1 hash. +func KeychainHasCertificate(keychain string, sha1hash string) bool { + output, err := executeSecurity("find-certificate", "-Z", keychain) + //will also err if the keychain is empty + if err != nil { + return false + } + return strings.Contains(output, strings.ToUpper(sha1hash)) +} + +func executeSecurity(args ...string) (string, error) { + cmd := exec.Command(securityPath, args...) + + output, err := cmd.CombinedOutput() + if err != nil { + log.WithFields(log.Fields{"cmd": cmd, "output": string(output)}).Errorf("security failed") + } + log.WithFields(log.Fields{"cmd": cmd, "output": string(output)}).Debugf("security invoked") + return string(output), err +} diff --git a/ios/codesign/security_test.go b/ios/codesign/security_test.go new file mode 100644 index 00000000..c2ba3b89 --- /dev/null +++ b/ios/codesign/security_test.go @@ -0,0 +1,126 @@ +package codesign_test + +import ( + "os" + "path" + "testing" + + "github.com/danielpaulus/go-ios/ios/codesign" + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" +) + +func TestCreateKeychain(t *testing.T) { + directory, err := os.MkdirTemp("", "codesign-test") + if err != nil { + log.Fatalf("Failed with %+v", err) + } + defer os.RemoveAll(directory) + keychain := path.Join(directory, "test.keychain") + + err = codesign.CreateKeychain(keychain) + if assert.NoError(t, err) { + //will fail if the file does not exist + info, err := os.Stat(keychain) + if assert.NoError(t, err) { + assert.False(t, info.IsDir()) + } + } +} + +func TestInstallCertificate(t *testing.T) { + directory, err := os.MkdirTemp("", "codesign-test") + if err != nil { + log.Fatalf("Failed with %+v", err) + } + defer os.RemoveAll(directory) + keychain := path.Join(directory, "test.keychain") + err = codesign.CreateKeychain(keychain) + codesign.UnlockKeychain(keychain) + codesign.DisableTimeoutForKeychain(keychain) + + certsha1, certpath := extractFixtureCertificate(directory) + + assert.False(t, codesign.KeychainHasCertificate(keychain, certsha1)) + codesign.AddX509CertificateToKeychain(keychain, certpath) + assert.True(t, codesign.KeychainHasCertificate(keychain, certsha1)) + +} + +func extractFixtureCertificate(tempdir string) (string, string) { + profileAndCertificate, err := codesign.ParseProfile("fixtures/test.mobileprovision") + if err != nil { + log.Fatal(err) + } + certsha1 := profileAndCertificate.CertificateSha1 + certPath := path.Join(tempdir, "test.p12") + err = os.WriteFile(certPath, profileAndCertificate.P12Bytes, 0644) + if err != nil { + log.Fatal(err) + } + return certsha1, certPath +} + +// Be careful with these tests and the underlying code, they use the actual keychain searchlist. +// If you mess it up, you might remove all your system passwords until you restore +// the keychain search list. So if you work on this code, be sure to call +// "security list-keychain" first and write your current list down somewhere :-) +func TestGetAndSetChangesNothing(t *testing.T) { + originalList, err := codesign.GetKeychainSearchList() + if err != nil { + log.Fatalf("Test failed getting keychain list %+v", err) + } + err = codesign.SetKeychainSearchList(originalList) + if err != nil { + log.Fatalf("Failed setting keychain with %+v", err) + } + listAfterTesting, err := codesign.GetKeychainSearchList() + if err != nil { + log.Fatalf("Test failed getting keychain list %+v", err) + } + assert.ElementsMatch(t, originalList, listAfterTesting) +} + +func TestAddRemoveKeychain(t *testing.T) { + originalList, err := codesign.GetKeychainSearchList() + if err != nil { + log.Fatalf("Test failed getting keychain list %+v", err) + } + + randomPath := "/Library/test/keychain.keychain" + err = codesign.AddKeychainToSearchList(randomPath) + if err != nil { + log.Fatalf("Test failed adding keychain list %+v", err) + } + + listWithRandomPath, err := codesign.GetKeychainSearchList() + if err != nil { + log.Fatalf("Test failed getting keychain list %+v", err) + } + assert.Contains(t, listWithRandomPath, randomPath) + + err = codesign.RemoveFromKeychainSearchList(randomPath) + if err != nil { + log.Fatalf("Test failed removing from keychain list %+v", err) + } + + listAfterTesting, err := codesign.GetKeychainSearchList() + if err != nil { + log.Fatalf("Test failed getting keychain list %+v", err) + } + assert.ElementsMatch(t, originalList, listAfterTesting) +} + +func TestRemoveNonPresentItemDoesNothing(t *testing.T) { + originalList, err := codesign.GetKeychainSearchList() + if err != nil { + log.Fatalf("Test failed getting keychain list %+v", err) + } + codesign.RemoveFromKeychainSearchList("not in the list because invalid filepath") + + listAfterTesting, err := codesign.GetKeychainSearchList() + if err != nil { + log.Fatalf("Test failed getting keychain list %+v", err) + } + assert.ElementsMatch(t, originalList, listAfterTesting) +} diff --git a/ios/codesign/unzip.go b/ios/codesign/unzip.go new file mode 100644 index 00000000..8dec0b0f --- /dev/null +++ b/ios/codesign/unzip.go @@ -0,0 +1,107 @@ +package codesign + +import ( + "archive/zip" + "fmt" + "io" + "os" + "path/filepath" + "runtime" + "strings" + "time" +) + +// ExtractIpa takes a io.ReaderAt and a length to extract a zip archive to a +// temporary directory that will be returned as a string path. +// It automatically skips "__MACOSX" resource fork folders, which mac os sometimes adds to zip files. +// Zipping those will break ipa files. +// It returns duration of the process, the temp directory containing the extracted files +// or an error. +// It is the callers responsibility to clean up the temp dir. +func ExtractIpa(zipFile io.ReaderAt, length int64) (time.Duration, string, error) { + destination, err := os.MkdirTemp("", "goios-ipa-extract") + if err != nil { + return 0, "", err + } + + start := time.Now() + r, err := zip.NewReader(zipFile, length) + if err != nil { + return 0, "", err + } + + for _, zf := range r.File { + if isMacOsResourceForkFolder(zf.Name) { + continue + } + if err := unzipFile(zf, destination); err != nil { + return 0, "", err + } + } + + return time.Since(start), destination, nil +} + +func isMacOsResourceForkFolder(name string) bool { + return strings.Contains(name, "__MACOSX") +} + +func unzipFile(zf *zip.File, destination string) error { + if strings.HasSuffix(zf.Name, "/") { + return mkdir(filepath.Join(destination, zf.Name)) + } + + rc, err := zf.Open() + if err != nil { + return fmt.Errorf("%s: open compressed file: %v", zf.Name, err) + } + defer rc.Close() + + return writeNewFile(filepath.Join(destination, zf.Name), rc, zf.FileInfo().Mode()) +} + +func writeNewFile(fpath string, in io.Reader, fm os.FileMode) error { + err := os.MkdirAll(filepath.Dir(fpath), 0755) + if err != nil { + return fmt.Errorf("%s: making directory for file: %v", fpath, err) + } + + out, err := os.Create(fpath) + if err != nil { + return fmt.Errorf("%s: creating new file: %v", fpath, err) + } + defer out.Close() + + err = out.Chmod(fm) + if err != nil && runtime.GOOS != "windows" { + return fmt.Errorf("%s: changing file mode: %v", fpath, err) + } + + _, err = io.Copy(out, in) + if err != nil { + return fmt.Errorf("%s: writing file: %v", fpath, err) + } + return nil +} + +func writeNewSymbolicLink(fpath string, target string) error { + err := os.MkdirAll(filepath.Dir(fpath), 0755) + if err != nil { + return fmt.Errorf("%s: making directory for file: %v", fpath, err) + } + + err = os.Symlink(target, fpath) + if err != nil { + return fmt.Errorf("%s: making symbolic link for: %v", fpath, err) + } + + return nil +} + +func mkdir(dirPath string) error { + err := os.MkdirAll(dirPath, 0755) + if err != nil { + return fmt.Errorf("%s: making directory: %v", dirPath, err) + } + return nil +} diff --git a/ios/codesign/workspace.go b/ios/codesign/workspace.go new file mode 100644 index 00000000..4aff13f7 --- /dev/null +++ b/ios/codesign/workspace.go @@ -0,0 +1,158 @@ +package codesign + +import ( + "os" + "os/exec" + "path" + "strings" + + log "github.com/sirupsen/logrus" + "howett.net/plist" +) + +type certAndEntitlement struct { + certPath string + entitlementPath string + certsha1 string +} + +// SigningWorkspace contains the workdir of this instance, and allows for parsing provisioning profiles. +// It also keeps which certificates are stored where in the workspace dir and know where the keychain is. +type SigningWorkspace struct { + workdir string + profiles []ProfileAndCertificate + extractedFiles []certAndEntitlement + keychainPath string +} + +// NewSigningWorkspace set up a new Workspace with a new workdir +func NewSigningWorkspace(workdir string) SigningWorkspace { + return SigningWorkspace{workdir: workdir} +} + +// PrepareProfiles parses the mobileprovisioning profiles in the given profilesDir. +// It extracts entitlements and stores P12 files, as well associating the correct sha1 fingerprints. +func (s *SigningWorkspace) PrepareProfiles(profilesDir string) error { + profiles, err := ParseProfiles(profilesDir) + if err != nil { + log.WithFields(log.Fields{"err": err}).Error("loading profiles failed") + return err + } + err = os.Mkdir(path.Join(s.workdir, "sign"), 0777) + if err != nil { + log.Errorf("failed creating dir in workspace err: %+v", err) + return err + } + err = os.WriteFile(path.Join(s.workdir, "sign", "test.txt"), []byte("some file"), 0777) + if err != nil { + log.Errorf("failed creating sample file in workspace sign dir err: %+v", err) + return err + } + + log.Infof("found %d profiles", len(profiles)) + s.profiles = profiles + s.extractedFiles = make([]certAndEntitlement, len(profiles)) + for i, profile := range profiles { + log.Infof("extracting files for profile: %s", profile.MobileProvisioningProfile.Name) + bytes, err := plist.Marshal(profile.MobileProvisioningProfile.Entitlements, plist.XMLFormat) + if err != nil { + log.WithFields(log.Fields{"err": err}).Error("failed converting to plist") + return err + } + entitlementName := path.Join(s.workdir, profile.MobileProvisioningProfile.Name+"-entitlements.plist") + log.Infof("extracting entitlements to: '%s'", entitlementName) + err = os.WriteFile(entitlementName, bytes, 0644) + if err != nil { + log.WithFields(log.Fields{"err": err}).Error("writing entitlements failed") + return err + } + certfile := path.Join(s.workdir, profile.MobileProvisioningProfile.Name+"-signingcert.p12") + + log.Infof("extracting signing certificate %s to: '%s'", profile.CertificateSha1, certfile) + err = os.WriteFile(certfile, profile.P12Bytes, 0644) + if err != nil { + log.WithFields(log.Fields{"err": err}).Error("writing certificate failed") + return err + } + + s.extractedFiles[i] = certAndEntitlement{certPath: certfile, certsha1: profile.CertificateSha1, entitlementPath: entitlementName} + + } + return nil +} + +// PrepareKeychain creates a new Keychain, unlocks it, disables the timeout +// installs the certificates we found and adds the new keychain to the keychain search list. +func (s *SigningWorkspace) PrepareKeychain(keychainName string) error { + keychain := path.Join(s.workdir, keychainName) + err := CreateKeychain(keychain) + if err != nil { + log.WithFields(log.Fields{"err": err}).Error("creating keychain failed") + return err + } + log.Infof("keychain created: %s", keychain) + err = UnlockKeychain(keychain) + if err != nil { + log.WithFields(log.Fields{"err": err}).Error("unlocking keychain failed") + return err + } + + err = DisableTimeoutForKeychain(keychain) + if err != nil { + log.WithFields(log.Fields{"err": err}).Error("disabling timeout keychain failed") + return err + } + + for _, cert := range s.extractedFiles { + log.Infof("installing %s to keychain", cert.certPath) + err = AddX509CertificateToKeychain(keychain, cert.certPath) + if err != nil { + log.WithFields(log.Fields{"err": err}).Error("installing cert failed") + return err + } + } + err = AddKeychainToSearchList(keychain) + if err != nil { + log.WithFields(log.Fields{"err": err}).Errorf("failed adding keychain to searchlist") + return err + } + + s.keychainPath = keychain + return nil +} + +// Close removes the keychain that was created from the systems keychain search list +func (s *SigningWorkspace) Close() { + log.Infof("removing %s from keychain search list", s.keychainPath) + err := RemoveFromKeychainSearchList(s.keychainPath) + if err != nil { + log.WithFields(log.Fields{"err": err}).Warn("removing keychain from search list failed") + } +} + +// GetConfig creates codesign.SigningConfig from the workspace's internal data +func (s *SigningWorkspace) GetConfig(index int) SigningConfig { + return SigningConfig{ + CertSha1: strings.ToUpper(s.extractedFiles[index].certsha1), + EntitlementsFilePath: s.extractedFiles[index].entitlementPath, + KeychainPath: s.keychainPath, + ProfileBytes: s.profiles[index].RawData, + } +} + +// testSigning executes a simple codesign operation to check it works still. +func (s *SigningWorkspace) testSigning() error { + length := len(s.profiles) + + for i := 0; i < length; i++ { + config := s.GetConfig(i) + cmd := exec.Command("/usr/bin/codesign", "-vv", "--keychain", config.KeychainPath, "--deep", "--force", "--sign", config.CertSha1, path.Join(s.workdir, "sign", "test.txt")) + output, err := cmd.CombinedOutput() + if err != nil { + log.WithFields( + log.Fields{"cert": config.CertSha1, "error": err, "cmd": cmd, "output": string(output)}).Infof("codesign test signing failed") + return err + } + } + return nil +} diff --git a/ios/codesign/workspace_test.go b/ios/codesign/workspace_test.go new file mode 100644 index 00000000..f3e7f8ad --- /dev/null +++ b/ios/codesign/workspace_test.go @@ -0,0 +1,29 @@ +package codesign_test + +import ( + "os" + "testing" + + "github.com/danielpaulus/go-ios/ios/codesign" + log "github.com/sirupsen/logrus" +) + +func TestWorkspaceInit(t *testing.T) { + _, _, cleanUp := makeWorkspaceWithoutProfiles() + defer cleanUp() + +} + +func makeWorkspaceWithoutProfiles() (codesign.SigningWorkspace, string, func()) { + dir, err := os.MkdirTemp("", "codesign-test") + if err != nil { + log.Fatal(err) + } + + workspace := codesign.NewSigningWorkspace(dir) + + cleanUp := func() { + defer os.RemoveAll(dir) + } + return workspace, dir, cleanUp +} diff --git a/ios/codesign/zip.go b/ios/codesign/zip.go new file mode 100644 index 00000000..fdf097b3 --- /dev/null +++ b/ios/codesign/zip.go @@ -0,0 +1,78 @@ +package codesign + +import ( + "archive/zip" + "io" + "os" + "strings" +) + +//CompressToIpa compresses all files and directories in the given root folder to a zip +//and writes it to the out io.Writer. +func CompressToIpa(root string, out io.Writer) error { + zipWriter := zip.NewWriter(out) + defer zipWriter.Close() + + files, err := GetFiles(root) + if err != nil { + return err + } + + for _, file := range files { + if err = addFileToZip(zipWriter, file, root); err != nil { + return err + } + } + return nil +} + +func addFileToZip(zipWriter *zip.Writer, filename string, root string) error { + fileToZip, err := os.Open(filename) + if err != nil { + return err + } + defer fileToZip.Close() + + // Get the file information + info, err := fileToZip.Stat() + if err != nil { + return err + } + + header, err := zip.FileInfoHeader(info) + if err != nil { + return err + } + + //we remove the root from each of the filenames before zipping + //if root does not have a trailing slash, all files will start with / + //which will create a broken zip + if !strings.HasSuffix(root, "/") { + root += "/" + } + + // Using FileInfoHeader() above only uses the basename of the file. If we want + // to preserve the folder structure we can overwrite this with the full path. + header.Name = strings.Replace(filename, root, "", 1) + + //To properly store empty directories, this code is needed + if info.IsDir() { + header.Name += "/" + } else { + // Change to deflate to gain better compression + // see http://golang.org/pkg/archive/zip/#pkg-constants + header.Method = zip.Deflate + } + + writer, err := zipWriter.CreateHeader(header) + if err != nil { + return err + } + //if it is a dir, not need to write a file + if info.IsDir() { + return nil + } + + _, err = io.Copy(writer, fileToZip) + return err +}