Crafting a Swift Package Plugin for App Data Protection

From encryption to runtime

Geor Kasapidi
Better Programming

--

Protecting data and intellectual property is a common but challenging task in software development. No developer wishes their individual or team efforts to be unlawfully exploited by intruders or competitors. While iOS apps provide significant security — provided by Apple encryption and runtime sandbox — more in-depth analysis of the IPA file reveals that not all files are encrypted. Various resources can be easily accessed, even without jailbreaking. Apple partially solved this problem by implementing encryption of Core ML models, but many resources remain unprotected.

In this detailed article, I will show you how to implement automatic file encryption in your project using the power of SPM plugins.

Note: The source code for this article can be found in this repository.

To set the stage, we will first familiarize ourselves with the theoretical aspects of SPM plugins.

Swift Package Manager Plugins Explained

Swift package plugins encompass two categories: command plugins and build tool plugins. Command plugins are typically invoked using the swift package <command> <arguments> syntax, whereas build tool plugins integrate into the project’s build graph, triggering automatically.

The main objective of the build tool plugins is to provide commands that enhance package building. These commands can be either build or prebuild commands:

“Build” and “prebuild” indicate when a command runs: during or before the build process. If you want to create new code or resources, you should use prebuild commands:

Swift Package Manager insists that prebuild commands be defined as binary targets in the package manifest:

.binaryTarget(
name: "MyCompiledCommand",
path: "mycommand.artifactbundle.zip"
)

An artifact bundle is essentially a compressed directory with a certain structure:

mycommand refers to the executable binary command file (its name can be arbitrary), while info.json represents the artifact scheme:

{
"schemaVersion": "1.0",
"artifacts": {
"mycommand": {
"version": "0.0.1",
"type": "executable",
"variants": [
{
"path": "coolproject/bin/mycommand",
"supportedTriples": [
"x86_64-apple-macosx",
"arm64-apple-macosx"
]
}
]
}
}
}

Note: The project repository includes a script for building the artifact bundle.

For a plugin to utilize a command, it must be included in the plugin’s list of dependencies:

.plugin(
name: "MyPlugin",
capability: .buildTool(),
dependencies: ["MyCompiledCommand"]
)

Correspondingly, the plugin must be connected to the target:

.target(
name: "MyTarget",
plugins: ["MyPlugin"]
)

The structure of build tool plugins is simple:

  1. The primary logic of the plugin is encapsulated within the command code.
  2. The plugin’s code itself is designed to generate calls to the appropriate commands.
  3. Targets equip plugins with a context based on which the plugin produces calls.

In short, plugins link targets and commands together.

For a file encryption task, we would create a prebuild command and a plugin to execute it.

Developing a Command and Plugin

The command for file encryption will be introduced via an executable file, generated from an executable Swift package that utilizes the Swift Argument Parser.

It will require two arguments: the directory containing the files to be encrypted, and the destination directory where the encrypted files along with the generated code will be saved:

@Option(completion: CompletionKind.file(), transform: URL.init(fileURLWithPath:))
private var inputDir: URL

@Option(completion: CompletionKind.file(), transform: URL.init(fileURLWithPath:))
private var outputDir: URL

The logical flow of the command is straightforward:

  1. Acquire a list of files to be encrypted.
  2. Individually encrypt each file using a unique key, and save it to the output directory.
  3. Generate a code for retrieving the decrypted data at runtime.
  4. Save the generated code.
mutating func run() throws {
try self.recreateOutputDir()

var code: [String] = [
"import Foundation",
"import CryptoKit",
]

let files = try self.collectFilesToProtect()

for file in files {
let inName = file.lastPathComponent
let outName = UUID().uuidString
let key = SymmetricKey(size: .bits256)

try self.encrypt(file: file, outName: outName, key: key)

code.append(self.generateAccessCodeForFile(
outName: outName,
inName: inName,
key: key
))
}

try self.save(generatedCode: code)
}

Beyond this, there are only implementation details. Our main focus will be on the plugin:

@main
struct MyPlugin: BuildToolPlugin {
func createBuildCommands(context: PluginContext, target: Target) async throws -> [Command] {
try [
.prebuildCommand(
displayName: "Protect app data",
executable: context.tool(named: "mycommand").path,
arguments: [
"--input-dir",
target.directory.appending("Files"),
"--output-dir",
context.pluginWorkDirectory.appending("Out"),
],
outputFilesDirectory: context.pluginWorkDirectory.appending("Out")
),
]
}
}

Despite its apparent simplicity, there are key points in the code that needs further explanation. As previously stated, the plugin connects the target and the command. To access the target directory, use target.directory. This directory is read-only.

For creating new files, the plugin context offers a special sandbox directory — pluginWorkDirectory. In the prebuild command invocation, outputFilesDirectory must be included — files from this directory will be read by the build system post-command execution and processed in accordance with the subsequent heuristics:

  1. All *.swift files will be compiled as part of the target.
  2. Any other files will be treated as resources, subject to the rule=process, and incorporated into the target bundle following standard SPM rules.

Plugin demo

Now, we are prepared for deployment! We need to incorporate the security files into our demo target, simultaneously excluding them from the target sources in the package manifest. This way, we can prevent the original files from being included in the bundle:

.target(
name: "MyTarget",
exclude: ["Files"],
plugins: ["MyPlugin"]
)

We initiate the build and… there we go! Due to our designated plugin and command, the system will generate corresponding code for each file housed in the Files directory (for instance, cheat_code.txt):

public func load_cheat_code_txt() throws -> Data {
let data = try Data(contentsOf: Bundle.module.url(forResource: "C21E2BF2-DCEC-4A77-ACE4-110B918D4923", withExtension: nil)!)
let sealedBox = try ChaChaPoly.SealedBox(combined: data)
return try ChaChaPoly.open(sealedBox, using: .init(data: [0x8e, 0xbf, 0xf5, 0x3f, 0xf3, 0xfc, 0xb7, 0x22, 0x11, 0x68, 0x84, 0x18, 0x81, 0x3e, 0x80, 0xbf, 0x4e, 0x9a, 0xe3, 0xc1, 0x3d, 0xe8, 0x49, 0x22, 0x91, 0xc2, 0x21, 0x51, 0x88, 0xd7, 0xc2, 0x58]))
}

Important note — For larger, more critical projects, I recommend creating a unique key storage algorithm. Techniques can include obfuscation, inlining the code, or even uploading keys to the backend and retrieving them during the application’s runtime. Considering that jailbreak allows for both static and dynamic analysis of the application code, it is almost impossible to achieve complete protection. However, it is quite possible to significantly increase the complexity of reverse engineering.

Another important note — If you need to do more than just encrypt data, like compressing entire directories, you can use the AppleArchive framework. It’s a bit complicated to use as is, so I’ve made a simple tool to help. This tool makes using the framework as easy as typing a single line of code.

Conclusion

Our custom plugin has automated the entire file encryption process. All a developer needs to do is place files in the specified directory.

Following this, the plugin automatically handles encryption and code generation. The resulting code can then be called in the suitable section of the application to retrieve the decrypted files.

This process is both efficient and straightforward, greatly simplifying the task at hand.

--

--