1 import ABI49_0_0ExpoModulesCore
2 import LocalAuthentication
3 import Security
4 
5 public final class SecureStoreModule: Module {
6   public func definition() -> ModuleDefinition {
7     Name("ExpoSecureStore")
8 
9     Constants([
10       "AFTER_FIRST_UNLOCK": SecureStoreAccessible.afterFirstUnlock.rawValue,
11       "AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY": SecureStoreAccessible.afterFirstUnlockThisDeviceOnly.rawValue,
12       "ALWAYS": SecureStoreAccessible.always.rawValue,
13       "WHEN_PASSCODE_SET_THIS_DEVICE_ONLY": SecureStoreAccessible.whenPasscodeSetThisDeviceOnly.rawValue,
14       "ALWAYS_THIS_DEVICE_ONLY": SecureStoreAccessible.alwaysThisDeviceOnly.rawValue,
15       "WHEN_UNLOCKED": SecureStoreAccessible.whenUnlocked.rawValue,
16       "WHEN_UNLOCKED_THIS_DEVICE_ONLY": SecureStoreAccessible.whenPasscodeSetThisDeviceOnly.rawValue
17     ])
18 
19     AsyncFunction("getValueWithKeyAsync") { (key: String, options: SecureStoreOptions) -> String? in
20       guard let key = validate(for: key) else {
21         throw InvalidKeyException()
22       }
23 
24       let data = try searchKeyChain(with: key, options: options)
25 
26       guard let data = data else {
27         return nil
28       }
29 
30       return String(data: data, encoding: .utf8)
31     }
32 
33     AsyncFunction("setValueWithKeyAsync") { (value: String, key: String, options: SecureStoreOptions) -> Bool in
34       guard let key = validate(for: key) else {
35         throw InvalidKeyException()
36       }
37 
38       return try set(value: value, with: key, options: options)
39     }
40 
41     AsyncFunction("deleteValueWithKeyAsync") { (key: String, options: SecureStoreOptions) in
42       let searchDictionary = query(with: key, options: options)
43       SecItemDelete(searchDictionary as CFDictionary)
44     }
45   }
46 
47   private func set(value: String, with key: String, options: SecureStoreOptions) throws -> Bool {
48     var query = query(with: key, options: options)
49 
50     let valueData = value.data(using: .utf8)
51     query[kSecValueData as String] = valueData
52 
53     let accessibility = attributeWith(options: options)
54 
55     if !options.requireAuthentication {
56       query[kSecAttrAccessible as String] = accessibility
57     } else {
58       guard let _ = Bundle.main.infoDictionary?["NSFaceIDUsageDescription"] as? String else {
59         throw MissingPlistKeyException()
60       }
61       let accessOptions = SecAccessControlCreateWithFlags(kCFAllocatorDefault, accessibility, SecAccessControlCreateFlags.biometryCurrentSet, nil)
62       query[kSecAttrAccessControl as String] = accessOptions
63     }
64 
65     let status = SecItemAdd(query as CFDictionary, nil)
66 
67     switch status {
68     case errSecSuccess:
69       return true
70     case errSecDuplicateItem:
71       return try update(value: value, with: key, options: options)
72     default:
73       throw KeyChainException(status)
74     }
75   }
76 
77   private func update(value: String, with key: String, options: SecureStoreOptions) throws -> Bool {
78     var query = query(with: key, options: options)
79 
80     let valueData = value.data(using: .utf8)
81     let updateDictionary = [kSecValueData as String: valueData]
82 
83     if let authPrompt = options.authenticationPrompt {
84       query[kSecUseOperationPrompt as String] = authPrompt
85     }
86 
87     let status = SecItemUpdate(query as CFDictionary, updateDictionary as CFDictionary)
88 
89     if status == errSecSuccess {
90       return true
91     } else {
92       throw KeyChainException(status)
93     }
94   }
95 
96   private func searchKeyChain(with key: String, options: SecureStoreOptions) throws -> Data? {
97     var query = query(with: key, options: options)
98 
99     query[kSecMatchLimit as String] = kSecMatchLimitOne
100     query[kSecReturnData as String] = kCFBooleanTrue
101 
102     if let authPrompt = options.authenticationPrompt {
103       query[kSecUseOperationPrompt as String] = authPrompt
104     }
105 
106     var item: CFTypeRef?
107     let status = SecItemCopyMatching(query as CFDictionary, &item)
108 
109     switch status {
110     case errSecSuccess:
111       guard let item = item as? Data else {
112         return nil
113       }
114 
115       return item
116     case errSecItemNotFound:
117       return nil
118     default:
119       throw KeyChainException(status)
120     }
121   }
122 
123   private func query(with key: String, options: SecureStoreOptions) -> [String: Any] {
124     let service = options.keychainService ?? "app"
125     let encodedKey = Data(key.utf8)
126 
127     return [
128       kSecClass as String: kSecClassGenericPassword,
129       kSecAttrService as String: service,
130       kSecAttrGeneric as String: encodedKey,
131       kSecAttrAccount as String: encodedKey
132     ]
133   }
134 
135   private func attributeWith(options: SecureStoreOptions) -> CFString {
136     switch options.keychainAccessible {
137     case .afterFirstUnlock:
138       return kSecAttrAccessibleAfterFirstUnlock
139     case .afterFirstUnlockThisDeviceOnly:
140       return kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
141     case .always:
142       return kSecAttrAccessibleAlways
143     case .whenPasscodeSetThisDeviceOnly:
144       return kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly
145     case .whenUnlocked:
146       return kSecAttrAccessibleWhenUnlocked
147     case .alwaysThisDeviceOnly:
148       return kSecAttrAccessibleAlwaysThisDeviceOnly
149     case .whenUnlockedThisDeviceOnly:
150       return kSecAttrAccessibleWhenUnlockedThisDeviceOnly
151     default:
152       return kSecAttrAccessibleWhenUnlocked
153     }
154   }
155 
156   private func validate(for key: String) -> String? {
157     let trimmedKey = key.trimmingCharacters(in: .whitespaces)
158     if trimmedKey.isEmpty {
159       return nil
160     }
161     return key
162   }
163 }
164