├── .gitignore ├── Sources └── KeychainStored │ ├── TopLevelEncoder.swift │ ├── TopLevelDecoder.swift │ ├── KeychainStored+EquatableHashable.swift │ └── KeychainStored.swift ├── Package.swift ├── LICENSE ├── README.md └── Tests └── KeychainStoredTests └── KeychainStoredTests.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | .swiftpm -------------------------------------------------------------------------------- /Sources/KeychainStored/TopLevelEncoder.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public protocol TopLevelEncoder { 4 | associatedtype Output 5 | 6 | func encode(_ value: T) throws -> Self.Output where T : Encodable 7 | } 8 | 9 | extension JSONEncoder: TopLevelEncoder {} 10 | extension PropertyListEncoder: TopLevelEncoder {} 11 | -------------------------------------------------------------------------------- /Sources/KeychainStored/TopLevelDecoder.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public protocol TopLevelDecoder { 4 | associatedtype Input 5 | 6 | func decode(_ type: T.Type, from: Self.Input) throws -> T where T : Decodable 7 | } 8 | 9 | extension JSONDecoder: TopLevelDecoder {} 10 | extension PropertyListDecoder: TopLevelDecoder {} 11 | -------------------------------------------------------------------------------- /Sources/KeychainStored/KeychainStored+EquatableHashable.swift: -------------------------------------------------------------------------------- 1 | extension KeychainStored: Equatable where Value: Equatable { 2 | public static func == (lhs: KeychainStored, rhs: KeychainStored) -> Bool { 3 | lhs.service == rhs.service && lhs.wrappedValue == rhs.wrappedValue 4 | } 5 | } 6 | 7 | extension KeychainStored: Hashable where Value: Hashable { 8 | public func hash(into hasher: inout Hasher) { 9 | service.hash(into: &hasher) 10 | wrappedValue?.hash(into: &hasher) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.1 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "KeychainStored", 7 | platforms: [ 8 | .iOS(.v9), 9 | .tvOS(.v9), 10 | .macOS(.v10_10), 11 | .watchOS(.v2) 12 | ], 13 | products: [ 14 | .library( 15 | name: "KeychainStored", 16 | targets: ["KeychainStored"]), 17 | ], 18 | targets: [ 19 | .target( 20 | name: "KeychainStored", 21 | dependencies: []), 22 | .testTarget( 23 | name: "KeychainStoredTests", 24 | dependencies: ["KeychainStored"] 25 | ), 26 | ] 27 | ) 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 OWOW 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🔑 KeychainStored 2 | 3 | A simple Swift property wrapper for storing data in the keychain. It supports: 4 | 5 | - All types that are `Codable`. 6 | - iOS 9+, watchOS 2+, macOS 10.10+, and tvOS 9+. 7 | 8 | ### 👶 Usage 9 | 10 | Declare a property in one of your types: 11 | 12 | ```swift 13 | @KeychainStored(service: "com.example.my-service") var myString: String 14 | ``` 15 | 16 | Then just use it like any other property: 17 | 18 | ```swift 19 | // To delete 20 | myString = nil 21 | 22 | // To set 23 | myString = "Some value" 24 | 25 | // To get 26 | if let myString = myString { 27 | // 🚀 28 | } 29 | ``` 30 | 31 | Note that wrapped values are always accessed as optionals. 32 | 33 | 34 | That's all there is to it! 35 | 36 | ### 💼 Custom encoder / decoder 37 | 38 | By default, the property wrapper will use a `JSONEncoder()` and `JSONDecoder()` to encode values, but you can use a custom encoder and/or decoder. 39 | 40 | The coders must implement the `TopLevelEncoder` and `TopLevelDecoder` protocols, where the input and output types are required to be `Data`. `JSONEncoder`, `JSONDecoder`, `PropertyListEncoder`, and `PropertyListDecoder` work out of the box. 41 | 42 | Note that the coders are not used if the type of the wrapped value is `String`. Strings are always stored directly with UTF-8 encoding. 43 | 44 | To use your custom coders: 45 | 46 | ```swift 47 | @KeychainStored(service: "...", encoder: PropertyListEncoder(), decoder: PropertyListDecoder()) var myVar: MyStruct 48 | ``` 49 | 50 | ### 🌳 Logging 51 | 52 | By default, errors are logged using `print`. The initialiser of the `@KeychainStored` property wrapper accepts a `logger` closure that you can use to customise logging behaviour. 53 | -------------------------------------------------------------------------------- /Tests/KeychainStoredTests/KeychainStoredTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import KeychainStored 3 | 4 | fileprivate struct TestStruct: Codable, Equatable { 5 | var foo = "Hello" 6 | var date = Date() 7 | } 8 | 9 | final class KeychainStoredTests: XCTestCase { 10 | 11 | @KeychainStored(service: "testString") private var wrappedString: String 12 | @KeychainStored(service: "testStruct") private var wrappedStruct: TestStruct 13 | 14 | override func setUp() { 15 | wrappedString = nil 16 | _wrappedString = KeychainStored(service: _wrappedString.service) 17 | 18 | wrappedStruct = nil 19 | _wrappedStruct = KeychainStored(service: _wrappedStruct.service) 20 | } 21 | 22 | override func tearDown() { 23 | wrappedString = nil 24 | wrappedStruct = nil 25 | } 26 | 27 | func testStoreString() { 28 | XCTAssertNil(self.wrappedString, "Start clean") 29 | 30 | let testString = "kaas" 31 | self.wrappedString = testString 32 | 33 | let second = KeychainStored(service: _wrappedString.service) 34 | XCTAssertEqual(wrappedString, testString) 35 | XCTAssertEqual(second.wrappedValue, testString) 36 | 37 | let third = KeychainStored(service: _wrappedStruct.service) // String type with struct service on purpose – to check that the services are correctly separated 38 | XCTAssertNil(third.wrappedValue) /// Checks that the services are correctly separated 39 | XCTAssertNil(self.wrappedStruct) 40 | } 41 | 42 | func testStoreStruct() { 43 | XCTAssertNil(self.wrappedStruct, "Start clean") 44 | 45 | let testStruct = TestStruct() 46 | self.wrappedStruct = testStruct 47 | 48 | let second = KeychainStored(service: _wrappedStruct.service) 49 | XCTAssertEqual(wrappedStruct, testStruct) 50 | XCTAssertEqual(second.wrappedValue, testStruct) 51 | } 52 | 53 | func testDeleting() { 54 | wrappedString = "Hi!" 55 | 56 | let second = KeychainStored(service: _wrappedString.service) 57 | XCTAssertNotNil(second.wrappedValue) 58 | XCTAssertNotNil(wrappedString) 59 | 60 | wrappedString = nil 61 | let third = KeychainStored(service: _wrappedString.service) 62 | XCTAssertNil(third.wrappedValue) 63 | XCTAssertNil(wrappedString) 64 | } 65 | 66 | } 67 | -------------------------------------------------------------------------------- /Sources/KeychainStored/KeychainStored.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Security 3 | 4 | /// A value that is stored in the keychain. 5 | @propertyWrapper 6 | public struct KeychainStored where ValueEncoder.Output == Data, ValueDecoder.Input == Data { 7 | 8 | /// A closure that can log strings. 9 | public typealias Logger = ((String) -> Void) 10 | 11 | // MARK: State 12 | 13 | /// The security class for the item. 14 | private let securityClass = kSecClassGenericPassword 15 | 16 | /// The value for `kSecAttrService`. 17 | public let service: String 18 | 19 | /// The value that is stored in the keychain. 20 | public var wrappedValue: Value? { 21 | didSet { 22 | storeValueInKeychain(wrappedValue) 23 | } 24 | } 25 | 26 | /// The logger used to log errors. 27 | private let logger: Logger? 28 | 29 | /// The encoder used to encode values. 30 | private let encoder: ValueEncoder 31 | 32 | /// The decoder used to decode values. 33 | private let decoder: ValueDecoder 34 | 35 | // MARK: Init 36 | 37 | /// Initialise a keychain stored value. 38 | /// - parameter service: An identifier for the value, stored in `kSecAttrService`. 39 | /// - parameter logger: When set, errors are logged using this closure. 40 | /// - parameter encoder: The encoder to use to encode values. Note that the encoder is not used if the value is a String – they are stored directly as UTF-8 instead. 41 | /// - parameter decoder: The decoder to use to decode values. Note that the decoder is not used if the value is a String – they are stored directly as UTF-8 instead. 42 | public init(service: String, logger: Logger? = { print($0) }, encoder: ValueEncoder, decoder: ValueDecoder) { 43 | self.service = service 44 | self.logger = logger 45 | self.encoder = encoder 46 | self.decoder = decoder 47 | 48 | self.wrappedValue = loadValueFromKeychain() 49 | } 50 | 51 | // MARK: - Keychain interactions 52 | 53 | // MARK: Query 54 | 55 | private var searchQuery: [String: Any] { 56 | [ 57 | kSecClass as String: securityClass, 58 | kSecAttrService as String: service 59 | ] 60 | } 61 | 62 | // MARK: Loading the value from the keychain 63 | 64 | /// Loads the value from the keychain. 65 | private func loadValueFromKeychain() -> Value? { 66 | var searchQuery = self.searchQuery 67 | searchQuery[kSecReturnAttributes as String] = true 68 | searchQuery[kSecReturnData as String] = true 69 | 70 | var unknownItem: CFTypeRef? 71 | let status = SecItemCopyMatching(searchQuery as CFDictionary, &unknownItem) 72 | 73 | guard status != errSecItemNotFound else { 74 | return nil // No value isn't an error 75 | } 76 | 77 | guard status == errSecSuccess else { 78 | reportError(status, operation: "loading") 79 | return nil 80 | } 81 | 82 | guard let item = unknownItem as? [String: Any], let data = item[kSecValueData as String] as? Data else { 83 | reportError(KeychainStoredError.unexpectedData, operation: "loading") 84 | return nil 85 | } 86 | 87 | return decodeValue(from: data) 88 | } 89 | 90 | /// Decodes the value from the given data. 91 | private func decodeValue(from data: Data) -> Value? { 92 | if Value.self == String.self { 93 | return String(data: data, encoding: .utf8) as! Value? 94 | } else { 95 | do { 96 | return try self.decoder.decode(Value.self, from: data) 97 | } catch { 98 | reportError(error, operation: "decoding") 99 | return nil 100 | } 101 | } 102 | } 103 | 104 | // MARK: Storing the value in the keychain 105 | 106 | /// Stores the given `value` in the keychain. 107 | private func storeValueInKeychain(_ value: Value?) { 108 | guard let encoded = encodeValue(value) else { 109 | deleteFromKeychain() 110 | return 111 | } 112 | 113 | let attributes: [String: Any] = [ 114 | kSecValueData as String: encoded 115 | ] 116 | 117 | var status = SecItemUpdate( 118 | searchQuery as CFDictionary, 119 | attributes as CFDictionary 120 | ) 121 | 122 | if status == errSecItemNotFound { 123 | /// Add the item if there was nothing to update. 124 | let addQuery = searchQuery.merging(attributes, uniquingKeysWith: { (_, new) in new }) 125 | status = SecItemAdd(addQuery as CFDictionary, nil) 126 | } 127 | 128 | guard status == errSecSuccess else { 129 | reportError(status, operation: "storing") 130 | return 131 | } 132 | } 133 | 134 | /// Encodes the given value to data. 135 | private func encodeValue(_ value: Value?) -> Data? { 136 | guard let value = value else { 137 | return nil 138 | } 139 | 140 | if Value.self == String.self { 141 | let string = value as! String 142 | return Data(string.utf8) 143 | } else { 144 | do { 145 | return try encoder.encode(value) 146 | } catch { 147 | reportError(error, operation: "encoding") 148 | return nil 149 | } 150 | } 151 | } 152 | 153 | // MARK: Deleting the value 154 | 155 | /// Deletes the item from the keychain. 156 | private func deleteFromKeychain() { 157 | let status = SecItemDelete(self.searchQuery as CFDictionary) 158 | 159 | guard status == errSecSuccess || status == errSecItemNotFound else { 160 | reportError(status, operation: "deleting") 161 | return 162 | } 163 | } 164 | 165 | // MARK: - Reporting Errors 166 | 167 | /// - parameter status: The error to report. 168 | /// - parameter operation: Will be used like this: "Error while \(operation) keychain item ..." 169 | private func reportError(_ status: OSStatus, operation: String) { 170 | guard let logger = self.logger else { return } 171 | 172 | if #available(iOS 11.3, tvOS 11.3, watchOS 4.3, *), let error = SecCopyErrorMessageString(status, nil) { 173 | logger("Error while \(operation) keychain item for service \(service): \(error)") 174 | } else { 175 | logger("Error while \(operation) keychain item for service \(service): \(status)") 176 | } 177 | } 178 | 179 | /// - parameter status: The error to report. 180 | /// - parameter operation: Will be used like this: "Error while \(operation) keychain item ..." 181 | private func reportError(_ error: Error, operation: String) { 182 | guard let logger = self.logger else { return } 183 | 184 | logger("Error while \(operation) keychain item for service \(service): \(error)") 185 | } 186 | } 187 | 188 | extension KeychainStored where ValueEncoder == JSONEncoder, ValueDecoder == JSONDecoder { 189 | /// This initialiser exists so you can use `@KeychainStored` like this: `@KeychainStored(service: "com.example") var mySecret: String?`. 190 | /// Therefore, it defaults to using a standard `JSONEncoder` and `JSONDecoder`. 191 | /// 192 | /// Initialise a keychain stored value. 193 | /// - parameter service: An identifier for the value, stored in `kSecAttrService`. 194 | /// - parameter logger: When set, errors are logged using this closure. 195 | /// - parameter encoder: The encoder to use to encode values. Note that the encoder is not if the value is a String – they are stored directly as UTF-8 instead. 196 | /// - parameter decoder: The decoder to use to decode values. Note that the decoder is not if the value is a String – they are stored directly as UTF-8 instead. 197 | public init(service: String, logger: Logger? = { print($0) }, jsonEncoder encoder: ValueEncoder = .init(), jsonDecoder decoder: ValueDecoder = .init()) { 198 | /// note: The argument labels are `jsonEncoder` / `jsonDecoder` instead of just `encoder` / `decoder` because otherwise this init would call itself. 199 | self.init(service: service, logger: logger, encoder: encoder, decoder: decoder) 200 | } 201 | } 202 | 203 | enum KeychainStoredError: Error { 204 | case unexpectedData 205 | } 206 | --------------------------------------------------------------------------------