├── .gitignore ├── LICENSE.md ├── Package.swift ├── Plugins └── SecretsManagerPlugin │ └── Plugin.swift ├── README.md └── Sources └── SecretsManager └── main.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .build 2 | .swiftpm 3 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Ethan Jackwitz 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.7 2 | import PackageDescription 3 | 4 | let package = Package( 5 | name: "SecretsManager", 6 | platforms: [.iOS(.v14), .macOS(.v13)], 7 | products: [ 8 | .executable(name: "SecretsManager", targets: ["SecretsManager"]), 9 | .plugin(name: "SecretsManagerPlugin", targets: ["SecretsManagerPlugin"]), 10 | ], 11 | targets: [ 12 | .executableTarget( 13 | name: "SecretsManager", 14 | dependencies: [] 15 | ), 16 | .plugin(name: "SecretsManagerPlugin", capability: .buildTool(), dependencies: ["SecretsManager"]), 17 | ] 18 | ) 19 | -------------------------------------------------------------------------------- /Plugins/SecretsManagerPlugin/Plugin.swift: -------------------------------------------------------------------------------- 1 | 2 | import PackagePlugin 3 | import class Foundation.FileManager 4 | 5 | @main 6 | struct SecretsManagerPlugin: BuildToolPlugin { 7 | 8 | func createBuildCommands(context: PluginContext, target: Target) async throws -> [Command] { 9 | let envPath = context.package.directory.appending(subpath: ".env") 10 | let outPath = context.pluginWorkDirectory.appending(subpath: "GeneratedSecrets.swift") 11 | guard FileManager.default.fileExists(atPath: envPath.string) else { 12 | Diagnostics.error("❗️ No .env file at path '\(envPath)'") 13 | return [] 14 | } 15 | return [command("SecretsManager", executable: try context.tool(named: "SecretsManager").path, envPath: envPath, outPath: outPath)] 16 | } 17 | } 18 | 19 | #if canImport(XcodeProjectPlugin) 20 | import XcodeProjectPlugin 21 | 22 | extension SecretsManagerPlugin: XcodeBuildToolPlugin { 23 | 24 | func createBuildCommands(context: XcodePluginContext, target: XcodeTarget) throws -> [Command] { 25 | let envPath = context.xcodeProject.directory.appending(subpath: ".env") 26 | let outPath = context.pluginWorkDirectory.appending(subpath: "GeneratedSecrets.swift") 27 | guard FileManager.default.fileExists(atPath: envPath.string) else { 28 | Diagnostics.error("❗️ No .env file at path '\(envPath)'") 29 | return [] 30 | } 31 | return [command("SecretsManager", executable: try context.tool(named: "SecretsManager").path, envPath: envPath, outPath: outPath)] 32 | } 33 | } 34 | #endif 35 | 36 | func command(_ name: String, executable: Path, envPath: Path, outPath: Path) -> Command { 37 | .buildCommand( 38 | displayName: name, 39 | executable: executable, 40 | arguments: [envPath.string, outPath.string], 41 | inputFiles: [envPath], 42 | outputFiles: [outPath] 43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SecretsManager 2 | 3 | Effortless Secrets Management for Swift projects using Code Generation. 4 | A simpler approach than using GYB files as outlined by the [NSHipster article](https://nshipster.com/secrets/#cosmic-brain-obfuscate-secrets-using-code-generation) 5 | on Secret Management on iOS using Swift Build Tool Plugin (Swift 5.6+). 6 | 7 | ## Features 8 | 9 | - Provides a convenient way to keep secrets out of source code 10 | - Encrypts Secrets when they are at rest in your applications binary 11 | - Provides convenient access through global `Secrets` 12 | - Run as manual Script, SPM plugin or Xcode plugin 13 | - Less than 250 lines of Swift 14 | 15 | ## Usage 16 | 17 | Use a `.env` bash script to export Secrets you want available to your source code. 18 | 19 | ```bash 20 | #prefix ORG 21 | 22 | export ORG_API_CLIENT_SECRET=hrFL6LpsGQPsEQdipfTSlosI6topYTfhLNCfIvbfUz5r6nc72DMRbLL3msjuAFnY 23 | export ORG_ANALYTICS_KEY=dak37Qv5KGwNsQxVJxjJY2OtbUnGKXlm3mkDApSRfrAsTHQdFRSEfrA9yin5T4YT 24 | export ORG_BACKEND_KEY='KwphtrRhgOXcRd=p!73QnrQLuOj=rx8edJhMy52sWeQPKMxOxA8hNcDrG9=XRvAw' 25 | export ORG_LOGGER_KEY='oW7YQKg2eNcVjzRdmCtmgCCSBp2dpJlL5NC-Pj!asS5XdPG/--R2hE?/=I/TlotP' 26 | ``` 27 | 28 | Using this plugin the following Swift code is generated and available directly to your targets 29 | source code, no need for an import. 30 | 31 | ```swift 32 | // This file is automatically generated 33 | 34 | import struct Foundation.Data 35 | 36 | private func secret(_ secret: String) -> String { 37 | let data = Data(base64Encoded: secret) 38 | guard let data else { 39 | fatalError("Failed to decode a secret!") 40 | } 41 | 42 | func decrypt(_ data: Data) -> String { 43 | let key = Data(base64Encoded: "JbiOFqC+jH3l8pwCLE4Nca4f19M7YAbeTUo7rhnSSG7ctZMlc+dg5FI9o3zrSbCgFLtDd0uC9EcCC+jd6hlVDA==")! 44 | var output: [UTF8.CodeUnit] = [] 45 | for (offset, ch) in data.enumerated() { 46 | output.append(ch ^ key[offset % key.count]) 47 | } 48 | return String(bytes: output, encoding: .utf8)! 49 | } 50 | 51 | return decrypt(data) 52 | } 53 | 54 | enum Secrets { 55 | static let apiClientSecret = secret("TcrIWpby/A6io8xxaR9pGN55g4BXD3WXez5U3kCGLgaQ+9BDOpECggdHlg7dJ9OXJv8OJSnOuHRveIKoq187VQ==") 56 | static let analyticsKey = secret("QdnlJZfv+kiutetMXx91J+RnvZliUkmqLx9V6VKKJAPv2PhhMpcztjRP4g+/AeHEUukQMi3wtX57Yobovi0MWA==") 57 | static let backendKey = secret("bs/+ftTM3hWCvcRhfiowAY8o5IJVEleSOAVRk2uqcAu4//toCtJSlwVY8iygBMjvbPp7HwXhsDVFMtWFuG8Uew==") 58 | static let loggerKey = secret("Su+5T/H160+AvP9URjRfFcNco75cI0WNDzoJymmYJCLp+9AII41BhSFuliSPGfePOZYRRSPHy2g/QseJhnYhXA==") 59 | } 60 | ``` 61 | 62 | ## Setup 63 | 64 | > Requires Swift 5.6 (Xcode 13.3+) 65 | 66 | Create a `.env` file in your root directory (alongside `Package.swift` or your `*.xcodeproj`). 67 | You can define a prefix to strip from all your exported keys with `#prefix`. See [Usage](#usage) 68 | for an example `.env` file. 69 | 70 | ### Xcode Projects 71 | > [Visual Guide](https://github.com/vdka/SecretsManager/wiki/Xcode-Integration) 72 | 73 | 1. Add SecretsManager package 74 | - When prompted to **Choose Package Products for SecretsManager** don't select any products 75 | 2. In **Targets > Build Phases** add `SecretsManagerPlugin` to **Run Build Tool Plug-ins** 76 | 3. Build, triggering a prompt to trust the plugin 77 | - From the issue navigator you can goto the plugin and read the source code before trusting 78 | 79 | ### Swift Package Manager 80 | 81 | Add the following to your `Package.swift` files dependencies array: 82 | ```swift 83 | .package(url: "https://github.com/vdka/SecretsManager.git", from: "1.0.0"), 84 | ``` 85 | 86 | And to the targets Secrets should be available to, after their `dependencies`: 87 | ```swift 88 | plugins: [ 89 | .plugin(name: "SecretsManagerPlugin", package: "SecretsManager"), 90 | ] 91 | ``` 92 | 93 | ### Xcode Cloud 94 | 95 | When running in Xcode cloud you will want the secret values to come from the actual environment. In 96 | order to tell the plugin which keys to read from the environment from you can create a `.env` file 97 | that exports only the keys without associated values. 98 | 99 | ```bash 100 | #prefix ORG 101 | 102 | export ORG_API_CLIENT_SECRET 103 | export ORG_ANALYTICS_KEY 104 | export ORG_BACKEND_KEY 105 | export ORG_LOGGER_KEY 106 | ``` 107 | 108 | ## Security 109 | 110 | This package doesn't aim to keep your Secrets safe from intentional attacks. It's aim is to make it 111 | convenient to adopt best practice Secrets Management in Swift projects. It does this by ensuring 112 | keeping your secrets out of your Source Code doesn't sacrifice usability, and that when compiled 113 | into your application, they are not stored in plaintext. Remember 114 | [Client Secrecy is Impossible](https://nshipster.com/secrets/#universe-brain-client-secrecy-is-impossible). 115 | -------------------------------------------------------------------------------- /Sources/SecretsManager/main.swift: -------------------------------------------------------------------------------- 1 | 2 | import Foundation 3 | 4 | let usage = """ 5 | USAGE: generate-secrets 6 | 7 | ARGUMENTS: 8 | The input .env file path 9 | The output generated file path 10 | 11 | OPTIONS: 12 | -h, --help Show help information. 13 | """ 14 | 15 | if CommandLine.arguments.contains("-h") || CommandLine.arguments.contains("--help") { 16 | print(usage) 17 | exit(0) 18 | } 19 | 20 | // MARK: - Parse Arguments 21 | let envPath = CommandLine.arguments[safe: 1] 22 | let outPath = CommandLine.arguments[safe: 2] 23 | 24 | guard let envPath else { 25 | print("error: Missing expected argument ''") 26 | print(usage) 27 | exit(1) 28 | } 29 | 30 | guard let outPath else { 31 | print("error: Missing expected argument ''") 32 | print(usage) 33 | exit(1) 34 | } 35 | 36 | // MARK: - Read & Parse file 37 | 38 | guard FileManager.default.fileExists(atPath: envPath) else { 39 | print("warning: No .env file exists at path \(envPath)") 40 | exit(0) 41 | } 42 | 43 | let env = try String(contentsOfFile: envPath) 44 | 45 | let quotesCharacterSet = CharacterSet(charactersIn: #""'"#) 46 | 47 | var lines = env.split(separator: "\n") 48 | 49 | let prefix = lines 50 | .first(where: { $0.hasPrefix("#prefix ") })? 51 | .suffix(from: "#prefix ") 52 | .trimmingCharacters(in: .whitespaces) ?? "" 53 | 54 | var longestSecret = 0 55 | var keypairs = lines 56 | .compactMap { (line: Substring) -> (key: String, value: String)? in 57 | if !line.hasPrefix("export ") { 58 | return nil 59 | } 60 | let line = line.dropFirst("export ".count) 61 | let splits = line.split(separator: "=", maxSplits: 1) 62 | var key = splits[0] 63 | if key.hasPrefix(prefix) { 64 | key = key.dropFirst(prefix.count) 65 | // if key still begins with an underscore, lets remove that also 66 | if key.hasPrefix("_") { 67 | key = key.dropFirst() 68 | } 69 | } 70 | 71 | let value: String 72 | if splits.count == 2 { 73 | value = splits[1].trimmingCharacters(in: quotesCharacterSet) 74 | } else if let envValue = ProcessInfo.processInfo.environment[String(splits[0])] { 75 | value = envValue 76 | } else { 77 | print("warning: expected to find environment value for key \(splits[0])") 78 | value = "" // If we've got `export KEY` with no value, it's likely intentionally left blank 79 | } 80 | 81 | longestSecret = max(longestSecret, value.utf8.count) 82 | return (key: key.lowercased().camelized, value: String(value)) 83 | } 84 | 85 | // Success, the env file has no secrets, therefore we are done? 86 | guard !keypairs.isEmpty else { 87 | print("warning: No keys found in env file at path \(envPath)") 88 | exit(0) 89 | } 90 | 91 | // Generate a random cypher equal in length to the longest secret 92 | var cypher = Data(count: longestSecret) 93 | cypher.withUnsafeMutableBytes { 94 | let result = SecRandomCopyBytes(kSecRandomDefault, longestSecret, $0.baseAddress!) 95 | precondition(result == errSecSuccess, "Failed to generate a random cypher using SecRandomCopyBytes") 96 | } 97 | 98 | /// - Note: This is a lazy encryption scheme, simply using an XOR with the cypher 99 | func encrypted(_ text: String) -> Data { 100 | let text = Array(text.utf8) 101 | let key = Array(cypher) 102 | var output: [UTF8.CodeUnit] = [] 103 | for (offset, ch) in text.enumerated() { 104 | output.append(ch ^ key[offset % key.count]) 105 | } 106 | return Data(output) 107 | } 108 | 109 | func encoded(_ data: Data) -> String { 110 | data.base64EncodedString() 111 | } 112 | 113 | keypairs = keypairs 114 | .map({ (key: $0, value: encrypted($1)) }) 115 | .map({ (key: $0, value: encoded($1)) }) 116 | 117 | let swiftHeader = """ 118 | // This file is automatically generated 119 | 120 | import struct Foundation.Data 121 | 122 | private func secret(_ secret: String) -> String { 123 | let data = Data(base64Encoded: secret) 124 | guard let data else { 125 | fatalError("Failed to decode a secret!") 126 | } 127 | 128 | func decrypt(_ data: Data) -> String { 129 | let key = Data(base64Encoded: "\(cypher.base64EncodedString())")! 130 | var output: [UTF8.CodeUnit] = [] 131 | for (offset, ch) in data.enumerated() { 132 | output.append(ch ^ key[offset % key.count]) 133 | } 134 | return String(bytes: output, encoding: .utf8)! 135 | } 136 | 137 | return decrypt(data) 138 | } 139 | 140 | """ 141 | 142 | let enumMembers = keypairs.map { key, value in 143 | """ 144 | static let \(key) = secret("\(value)") 145 | """ 146 | } 147 | .joined(separator: "\n") 148 | 149 | let swiftSecretsEnum = """ 150 | 151 | enum Secrets { 152 | \(enumMembers) 153 | } 154 | 155 | 156 | """ 157 | 158 | let swiftFile = swiftHeader + swiftSecretsEnum 159 | guard let data = swiftFile.data(using: .utf8) else { 160 | print("warning: Generated file failed to encode into UTF8") 161 | exit(1) 162 | } 163 | 164 | let url = URL(fileURLWithPath: outPath) 165 | do { 166 | try data.write(to: url) 167 | print("Output at \(outPath)") 168 | } catch { 169 | print("error: Writing file failed with: \(error.localizedDescription)") 170 | } 171 | 172 | // MARK: - Extensions 173 | 174 | extension String { 175 | var uppercasingFirst: String { 176 | return prefix(1).uppercased() + dropFirst() 177 | } 178 | 179 | var lowercasingFirst: String { 180 | return prefix(1).lowercased() + dropFirst() 181 | } 182 | } 183 | 184 | extension Array { 185 | subscript(safe index: Index) -> String? { 186 | guard indices.contains(index) else { return nil } 187 | return self[index] 188 | } 189 | } 190 | 191 | extension StringProtocol { 192 | var camelized: String { 193 | guard !isEmpty else { 194 | return "" 195 | } 196 | 197 | let parts = self.components(separatedBy: CharacterSet.alphanumerics.inverted) 198 | 199 | let first = String(describing: parts.first!).lowercasingFirst 200 | let rest = parts.dropFirst().map({String($0).uppercasingFirst}) 201 | 202 | return ([first] + rest).joined(separator: "") 203 | } 204 | 205 | func suffix(from prefix: String) -> String { 206 | let index = index(startIndex, offsetBy: prefix.count) 207 | return String(self.suffix(from: index)) 208 | } 209 | } 210 | --------------------------------------------------------------------------------