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       var 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       let accessOptions = SecAccessControlCreateWithFlags(kCFAllocatorDefault, accessibility, SecAccessControlCreateFlags.biometryCurrentSet, nil)
59       query[kSecAttrAccessControl as String] = accessOptions
60     }
61 
62     let status = SecItemAdd(query as CFDictionary, nil)
63 
64     switch status {
65     case errSecSuccess:
66       return true
67     case errSecDuplicateItem:
68       return try update(value: value, with: key, options: options)
69     default:
70       throw KeyChainException(status)
71     }
72   }
73 
74   private func update(value: String, with key: String, options: SecureStoreOptions) throws -> Bool {
75     var query = query(with: key, options: options)
76 
77     let valueData = value.data(using: .utf8)
78     let updateDictionary = [kSecValueData as String: valueData]
79 
80     if let authPrompt = options.authenticationPrompt {
81       query[kSecUseOperationPrompt as String] = authPrompt
82     }
83 
84     let status = SecItemUpdate(query as CFDictionary, updateDictionary as CFDictionary)
85 
86     if status == errSecSuccess {
87       return true
88     } else {
89       throw KeyChainException(status)
90     }
91   }
92 
93   private func searchKeyChain(with key: String, options: SecureStoreOptions) throws -> Data? {
94     var query = query(with: key, options: options)
95 
96     query[kSecMatchLimit as String] = kSecMatchLimitOne
97     query[kSecReturnData as String] = kCFBooleanTrue
98 
99     if let authPrompt = options.authenticationPrompt {
100       query[kSecUseOperationPrompt as String] = authPrompt
101     }
102 
103     var item: CFTypeRef?
104     let status = SecItemCopyMatching(query as CFDictionary, &item)
105 
106     switch status {
107     case errSecSuccess:
108       guard let item = item as? Data else {
109         return nil
110       }
111 
112       return item
113     case errSecItemNotFound:
114       return nil
115     default:
116       throw KeyChainException(status)
117     }
118   }
119 
120   private func query(with key: String, options: SecureStoreOptions) -> [String: Any] {
121     let service = options.keychainService ?? "app"
122     let encodedKey = key.data(using: .utf8)
123 
124     return [
125       kSecClass as String: kSecClassGenericPassword,
126       kSecAttrService as String: service,
127       kSecAttrGeneric as String: encodedKey,
128       kSecAttrAccount as String: encodedKey
129     ]
130   }
131 
132   private func attributeWith(options: SecureStoreOptions) -> CFString {
133     switch options.keychainAccessible {
134     case .afterFirstUnlock:
135       return kSecAttrAccessibleAfterFirstUnlock
136     case .afterFirstUnlockThisDeviceOnly:
137       return kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
138     case .always:
139       return kSecAttrAccessibleAlways
140     case .whenPasscodeSetThisDeviceOnly:
141       return kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly
142     case .whenUnlocked:
143       return kSecAttrAccessibleWhenUnlocked
144     case .alwaysThisDeviceOnly:
145       return kSecAttrAccessibleAlwaysThisDeviceOnly
146     case .whenUnlockedThisDeviceOnly:
147       return kSecAttrAccessibleWhenUnlockedThisDeviceOnly
148     default:
149       return kSecAttrAccessibleWhenUnlocked
150     }
151   }
152 
153   private func validate(for key: String) -> String? {
154     let trimmedKey = key.trimmingCharacters(in: .whitespaces)
155     if trimmedKey.isEmpty {
156       return nil
157     }
158     return key
159   }
160 }
161