@tokamakdev/plugin-secure-storage 0.1.0-beta.45

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,70 @@
1
+ # @tokamakdev/plugin-secure-storage
2
+
3
+ Secret storage that stays on the device, for Tokamak applications.
4
+
5
+ Add it to your project's dependencies:
6
+
7
+ ```sh
8
+ npm install @tokamakdev/plugin-secure-storage
9
+ ```
10
+
11
+ ```ts
12
+ import { secureStorage } from "@tokamakdev/plugin-secure-storage";
13
+
14
+ await secureStorage.set("identity/encryption", base64Value, {
15
+ // Required: "whenUnlocked" or "afterFirstUnlock".
16
+ readable: "whenUnlocked",
17
+ // Reads need no authentication when omitted.
18
+ authentication: "biometricsOrPasscode",
19
+ });
20
+
21
+ // string | null
22
+ const value = await secureStorage.get("identity/encryption", {
23
+ prompt: "Confirm it's you to link a new device",
24
+ });
25
+
26
+ await secureStorage.delete("identity/encryption");
27
+ ```
28
+
29
+ Values are strings; encode binary values before storing them. Only the app can
30
+ read them, and they are never included in synced credential stores or restored
31
+ to another device.
32
+
33
+ `authentication` binds a value to the device's secure hardware, so it cannot be
34
+ decrypted until the device owner authenticates:
35
+
36
+ | `authentication` | Reads accept | After a biometric enrolment change |
37
+ |---|---|---|
38
+ | `biometricsOrPasscode` | Face ID, Touch ID or fingerprint; passcode, PIN, pattern or password | Readable |
39
+ | `biometrics` | Face ID, Touch ID or fingerprint | Readable |
40
+ | `currentBiometrics` | Face ID, Touch ID or fingerprint enrolled when the value was stored | Permanently unreadable |
41
+
42
+ `prompt` is the reason shown in the system authentication prompt. Without it,
43
+ the platform's default prompt is shown.
44
+
45
+ ## Errors
46
+
47
+ | Name | When |
48
+ |---|---|
49
+ | `NotSupportedError` | The platform, build or device cannot enforce the options |
50
+ | `InvalidStateError` | The requested authentication is not set up on the device |
51
+ | `NotAllowedError` | The user cancelled or failed authentication, or the device is locked |
52
+ | `NotReadableError` | A stored value can no longer be decrypted |
53
+ | `TypeError` | An argument is missing or has an unsupported value |
54
+ | `OperationError` | The platform reported another failure |
55
+
56
+ `get` resolves `null` when the name has no stored value.
57
+
58
+ ## Platforms
59
+
60
+ - **iOS:** Keychain items with `ThisDeviceOnly` accessibility. They are included
61
+ in encrypted backups but restore only to the same device, and they survive
62
+ deleting and reinstalling the app.
63
+ - **macOS:** the data protection keychain, which requires a team-signed build
64
+ (`macos-team-id`). Ad-hoc signed builds throw `NotSupportedError`.
65
+ - **Android:** values are encrypted with a per-value Android Keystore key and
66
+ stored in the app's no-backup directory. They are deleted on uninstall.
67
+ - **Web and Windows:** no implementation; calls throw `NotSupportedError`.
68
+
69
+ Removing the device passcode or screen lock makes values stored with
70
+ `authentication` permanently unreadable on Android.
@@ -0,0 +1,369 @@
1
+ package com.tokamak.plugins.securestorage
2
+
3
+ import android.app.Activity
4
+ import android.hardware.biometrics.BiometricManager
5
+ import android.hardware.biometrics.BiometricManager.Authenticators
6
+ import android.hardware.biometrics.BiometricPrompt
7
+ import android.os.CancellationSignal
8
+ import android.security.keystore.KeyGenParameterSpec
9
+ import android.security.keystore.KeyInfo
10
+ import android.security.keystore.KeyPermanentlyInvalidatedException
11
+ import android.security.keystore.KeyProperties
12
+ import android.security.keystore.StrongBoxUnavailableException
13
+ import android.security.keystore.UserNotAuthenticatedException
14
+ import android.util.AtomicFile
15
+ import com.tokamak.runtime.TokamakPlugin
16
+ import com.tokamak.runtime.TokamakPluginError
17
+ import com.tokamak.runtime.TokamakPluginReply
18
+ import java.io.File
19
+ import java.io.FileNotFoundException
20
+ import java.security.KeyFactory
21
+ import java.security.KeyPairGenerator
22
+ import java.security.KeyStore
23
+ import java.security.MessageDigest
24
+ import java.security.PrivateKey
25
+ import java.security.PublicKey
26
+ import java.security.SecureRandom
27
+ import java.security.spec.MGF1ParameterSpec
28
+ import java.security.spec.X509EncodedKeySpec
29
+ import java.util.concurrent.Executors
30
+ import javax.crypto.BadPaddingException
31
+ import javax.crypto.Cipher
32
+ import javax.crypto.IllegalBlockSizeException
33
+ import javax.crypto.KeyGenerator
34
+ import javax.crypto.spec.GCMParameterSpec
35
+ import javax.crypto.spec.OAEPParameterSpec
36
+ import javax.crypto.spec.PSource
37
+ import javax.crypto.spec.SecretKeySpec
38
+ import org.json.JSONObject
39
+
40
+ /**
41
+ * Stores each value sealed with a random AES-GCM key, wrapped by an RSA key pair in the
42
+ * Android Keystore. Writing uses only the public key, so it never prompts; the private key
43
+ * carries the value's unlock and authentication requirements. Every write uses a new key
44
+ * pair, identified in the value's file, so a failed write leaves the previous value readable.
45
+ */
46
+ internal class TokamakSecureStoragePlugin(
47
+ private val activity: Activity,
48
+ ) : TokamakPlugin {
49
+ override val id = "secure-storage"
50
+
51
+ private val worker = Executors.newSingleThreadExecutor()
52
+ private val directory = File(activity.noBackupFilesDir, "tokamak-secure-storage")
53
+ private val keyStore = KeyStore.getInstance(KEYSTORE).apply { load(null) }
54
+ private val biometrics = activity.getSystemService(BiometricManager::class.java)
55
+ private val random = SecureRandom()
56
+
57
+ override fun call(method: String, arguments: Any?, reply: TokamakPluginReply) {
58
+ when (method) {
59
+ "set" -> execute(reply) {
60
+ set(request(arguments))
61
+ reply(Result.success(null))
62
+ }
63
+ "get" -> execute(reply) { get(request(arguments), reply) }
64
+ "delete" -> execute(reply) {
65
+ delete(request(arguments).requireString("name"))
66
+ reply(Result.success(null))
67
+ }
68
+ else -> super.call(method, arguments, reply)
69
+ }
70
+ }
71
+
72
+ private fun execute(reply: TokamakPluginReply, operation: () -> Unit) =
73
+ worker.execute {
74
+ runCatching(operation).onFailure { reply(Result.failure(pluginError(it))) }
75
+ }
76
+
77
+ private fun onUiThread(reply: TokamakPluginReply, operation: () -> Unit) =
78
+ activity.runOnUiThread {
79
+ runCatching(operation).onFailure { reply(Result.failure(pluginError(it))) }
80
+ }
81
+
82
+ private fun set(request: JSONObject) {
83
+ val name = request.requireString("name")
84
+ val value = request.requireString("value")
85
+ val unlockedDeviceRequired =
86
+ when (request.requireString("readable")) {
87
+ "whenUnlocked" -> true
88
+ "afterFirstUnlock" -> false
89
+ else -> throw typeError("readable must be \"whenUnlocked\" or \"afterFirstUnlock\"")
90
+ }
91
+ val keyId = ByteArray(KEY_ID_SIZE).also(random::nextBytes)
92
+ val spec =
93
+ KeyGenParameterSpec.Builder(alias(keyId), KeyProperties.PURPOSE_DECRYPT)
94
+ .setKeySize(KEY_SIZE)
95
+ .setDigests(KeyProperties.DIGEST_SHA256)
96
+ .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_RSA_OAEP)
97
+ .setUnlockedDeviceRequired(unlockedDeviceRequired)
98
+ request.optionalString("authentication")?.let { requireAuthentication(spec, it) }
99
+ val file = file(name)
100
+ val previousAlias = read(file)?.let(::storedAlias)
101
+ val publicKey = generatePublicKey(spec)
102
+ try {
103
+ write(file, keyId + seal(value, publicKey))
104
+ } catch (error: Throwable) {
105
+ keyStore.deleteEntry(alias(keyId))
106
+ throw error
107
+ }
108
+ previousAlias?.let(keyStore::deleteEntry)
109
+ }
110
+
111
+ private fun requireAuthentication(spec: KeyGenParameterSpec.Builder, wireName: String) {
112
+ val authentication =
113
+ Authentication.entries.firstOrNull { it.wireName == wireName } ?: throw unknownAuthentication()
114
+ requireAvailable(authentication.authenticators)
115
+ spec.setUserAuthenticationRequired(true)
116
+ .setUserAuthenticationParameters(0, authentication.keyTypes)
117
+ .setInvalidatedByBiometricEnrollment(authentication.invalidatedByEnrollment)
118
+ }
119
+
120
+ private fun requireAvailable(authenticators: Int) {
121
+ when (biometrics.canAuthenticate(authenticators)) {
122
+ BiometricManager.BIOMETRIC_SUCCESS -> Unit
123
+ BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE ->
124
+ throw TokamakPluginError.notSupported("This device cannot perform the requested authentication")
125
+ else -> throw notSetUp()
126
+ }
127
+ }
128
+
129
+ private fun generatePublicKey(spec: KeyGenParameterSpec.Builder): PublicKey {
130
+ val generator = KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_RSA, KEYSTORE)
131
+ val keyPair =
132
+ try {
133
+ generator.initialize(spec.setIsStrongBoxBacked(true).build())
134
+ generator.generateKeyPair()
135
+ } catch (_: StrongBoxUnavailableException) {
136
+ generator.initialize(spec.setIsStrongBoxBacked(false).build())
137
+ generator.generateKeyPair()
138
+ }
139
+ // A software copy of the public key encrypts without Keystore restrictions.
140
+ return KeyFactory.getInstance(KeyProperties.KEY_ALGORITHM_RSA)
141
+ .generatePublic(X509EncodedKeySpec(keyPair.public.encoded))
142
+ }
143
+
144
+ private fun seal(value: String, publicKey: PublicKey): ByteArray {
145
+ val dataKey = KeyGenerator.getInstance("AES").apply { init(DATA_KEY_SIZE) }.generateKey()
146
+ val iv = ByteArray(IV_SIZE).also(random::nextBytes)
147
+ val sealed =
148
+ Cipher.getInstance(SEAL)
149
+ .apply { init(Cipher.ENCRYPT_MODE, dataKey, GCMParameterSpec(TAG_SIZE, iv)) }
150
+ .doFinal(value.toByteArray())
151
+ val wrapped =
152
+ Cipher.getInstance(WRAP)
153
+ .apply { init(Cipher.ENCRYPT_MODE, publicKey, OAEP) }
154
+ .doFinal(dataKey.encoded)
155
+ return wrapped + iv + sealed
156
+ }
157
+
158
+ private fun get(request: JSONObject, reply: TokamakPluginReply) {
159
+ val name = request.requireString("name")
160
+ val prompt = request.optionalString("prompt")
161
+ val stored = read(file(name))
162
+ if (stored == null) {
163
+ reply(Result.success(null))
164
+ return
165
+ }
166
+ val privateKey =
167
+ keyStore.getKey(storedAlias(stored), null) as? PrivateKey
168
+ ?: throw notReadable()
169
+ val unwrap = Cipher.getInstance(WRAP).apply { init(Cipher.DECRYPT_MODE, privateKey, OAEP) }
170
+ val info =
171
+ KeyFactory.getInstance(privateKey.algorithm, KEYSTORE)
172
+ .getKeySpec(privateKey, KeyInfo::class.java)
173
+ if (!info.isUserAuthenticationRequired) {
174
+ reply(Result.success(open(stored, unwrap)))
175
+ return
176
+ }
177
+ val credential = (info.userAuthenticationType and KeyProperties.AUTH_DEVICE_CREDENTIAL) != 0
178
+ val authenticators =
179
+ if (credential) Authenticators.BIOMETRIC_STRONG or Authenticators.DEVICE_CREDENTIAL
180
+ else Authenticators.BIOMETRIC_STRONG
181
+ authenticate(authenticators, prompt, unwrap, stored, reply)
182
+ }
183
+
184
+ /** Authorises [unwrap] with the device owner's authentication, then replies with the opened value. */
185
+ private fun authenticate(
186
+ authenticators: Int,
187
+ prompt: String?,
188
+ unwrap: Cipher,
189
+ stored: ByteArray,
190
+ reply: TokamakPluginReply,
191
+ ) {
192
+ val callback = PromptCallback(reply) { execute(reply) { reply(Result.success(open(stored, unwrap))) } }
193
+ val title = prompt?.takeIf { it.isNotEmpty() } ?: activity.applicationInfo.loadLabel(activity.packageManager)
194
+ val dialog =
195
+ BiometricPrompt.Builder(activity)
196
+ .setTitle(title)
197
+ .setAllowedAuthenticators(authenticators)
198
+ if ((authenticators and Authenticators.DEVICE_CREDENTIAL) == 0) {
199
+ dialog.setNegativeButton(
200
+ activity.getString(android.R.string.cancel),
201
+ activity.mainExecutor,
202
+ ) { _, _ -> reply(Result.failure(cancelled())) }
203
+ }
204
+ onUiThread(reply) {
205
+ dialog.build().authenticate(
206
+ BiometricPrompt.CryptoObject(unwrap),
207
+ CancellationSignal(),
208
+ activity.mainExecutor,
209
+ callback,
210
+ )
211
+ }
212
+ }
213
+
214
+ /** Opens a stored value laid out as key ID, wrapped data key, IV, then sealed value. */
215
+ private fun open(stored: ByteArray, unwrap: Cipher): String {
216
+ val dataKey = SecretKeySpec(unwrap.doFinal(stored, KEY_ID_SIZE, WRAPPED_KEY_SIZE), "AES")
217
+ val iv = stored.copyOfRange(IV_START, SEALED_START)
218
+ return Cipher.getInstance(SEAL)
219
+ .apply { init(Cipher.DECRYPT_MODE, dataKey, GCMParameterSpec(TAG_SIZE, iv)) }
220
+ .doFinal(stored, SEALED_START, stored.size - SEALED_START)
221
+ .decodeToString()
222
+ }
223
+
224
+ private fun delete(name: String) {
225
+ val file = file(name)
226
+ val alias = read(file)?.let(::storedAlias) ?: return
227
+ AtomicFile(file).delete()
228
+ keyStore.deleteEntry(alias)
229
+ }
230
+
231
+ private fun read(file: File): ByteArray? =
232
+ try {
233
+ AtomicFile(file).readFully()
234
+ } catch (_: FileNotFoundException) {
235
+ null
236
+ }
237
+
238
+ private fun write(file: File, bytes: ByteArray) {
239
+ directory.mkdirs()
240
+ val atomic = AtomicFile(file)
241
+ val output = atomic.startWrite()
242
+ try {
243
+ output.write(bytes)
244
+ atomic.finishWrite(output)
245
+ } catch (error: Throwable) {
246
+ atomic.failWrite(output)
247
+ throw error
248
+ }
249
+ }
250
+
251
+ private fun file(name: String) =
252
+ File(directory, MessageDigest.getInstance("SHA-256").digest(name.toByteArray()).toHex())
253
+
254
+ private fun alias(keyId: ByteArray) = "tokamak-secure-storage-${keyId.toHex()}"
255
+
256
+ private fun storedAlias(stored: ByteArray) = alias(stored.copyOf(KEY_ID_SIZE))
257
+
258
+ private fun ByteArray.toHex() = joinToString("") { "%02x".format(it) }
259
+
260
+ private fun request(arguments: Any?): JSONObject =
261
+ arguments as? JSONObject ?: throw typeError("$id arguments must be an object")
262
+
263
+ private fun JSONObject.requireString(name: String): String =
264
+ optionalString(name) ?: throw typeError("$name must be a string")
265
+
266
+ private fun JSONObject.optionalString(name: String): String? =
267
+ when (val value = opt(name)) {
268
+ null, JSONObject.NULL -> null
269
+ is String -> value
270
+ else -> throw typeError("$name must be a string")
271
+ }
272
+
273
+ private enum class Authentication(
274
+ val wireName: String,
275
+ val keyTypes: Int,
276
+ val authenticators: Int,
277
+ val invalidatedByEnrollment: Boolean,
278
+ ) {
279
+ BIOMETRICS_OR_PASSCODE(
280
+ "biometricsOrPasscode",
281
+ KeyProperties.AUTH_BIOMETRIC_STRONG or KeyProperties.AUTH_DEVICE_CREDENTIAL,
282
+ Authenticators.BIOMETRIC_STRONG or Authenticators.DEVICE_CREDENTIAL,
283
+ invalidatedByEnrollment = false,
284
+ ),
285
+ BIOMETRICS(
286
+ "biometrics",
287
+ KeyProperties.AUTH_BIOMETRIC_STRONG,
288
+ Authenticators.BIOMETRIC_STRONG,
289
+ invalidatedByEnrollment = false,
290
+ ),
291
+ CURRENT_BIOMETRICS(
292
+ "currentBiometrics",
293
+ KeyProperties.AUTH_BIOMETRIC_STRONG,
294
+ Authenticators.BIOMETRIC_STRONG,
295
+ invalidatedByEnrollment = true,
296
+ ),
297
+ }
298
+
299
+ private companion object {
300
+ const val KEYSTORE = "AndroidKeyStore"
301
+ const val KEY_SIZE = 2048
302
+ const val KEY_ID_SIZE = 16
303
+ const val WRAPPED_KEY_SIZE = KEY_SIZE / 8
304
+ const val DATA_KEY_SIZE = 256
305
+ const val IV_SIZE = 12
306
+ const val IV_START = KEY_ID_SIZE + WRAPPED_KEY_SIZE
307
+ const val SEALED_START = IV_START + IV_SIZE
308
+ const val TAG_SIZE = 128
309
+ const val WRAP = "RSA/ECB/OAEPWithSHA-256AndMGF1Padding"
310
+ const val SEAL = "AES/GCM/NoPadding"
311
+
312
+ // Keystore RSA keys default to SHA-1 for the OAEP MGF1 digest.
313
+ val OAEP = OAEPParameterSpec("SHA-256", "MGF1", MGF1ParameterSpec.SHA1, PSource.PSpecified.DEFAULT)
314
+
315
+ fun typeError(message: String) = TokamakPluginError("TypeError", message)
316
+
317
+ fun unknownAuthentication() =
318
+ typeError("authentication must be \"biometricsOrPasscode\", \"biometrics\" or \"currentBiometrics\"")
319
+
320
+ fun locked() = TokamakPluginError("NotAllowedError", "The device must be unlocked to read this value")
321
+
322
+ fun notReadable() =
323
+ TokamakPluginError("NotReadableError", "The stored value can no longer be decrypted")
324
+
325
+ fun cancelled() = TokamakPluginError("NotAllowedError", "Authentication was cancelled")
326
+
327
+ fun notSetUp() =
328
+ TokamakPluginError("InvalidStateError", "The requested authentication is not set up on this device")
329
+
330
+ fun pluginError(error: Throwable): TokamakPluginError =
331
+ when (error) {
332
+ is TokamakPluginError -> error
333
+ is KeyPermanentlyInvalidatedException,
334
+ is BadPaddingException,
335
+ is IllegalBlockSizeException,
336
+ -> notReadable()
337
+ is UserNotAuthenticatedException -> locked()
338
+ else -> TokamakPluginError("OperationError", error.message ?: error.javaClass.name)
339
+ }
340
+ }
341
+ }
342
+
343
+ private class PromptCallback(
344
+ private val reply: TokamakPluginReply,
345
+ private val succeeded: () -> Unit,
346
+ ) : BiometricPrompt.AuthenticationCallback() {
347
+ override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) = succeeded()
348
+
349
+ override fun onAuthenticationError(code: Int, message: CharSequence) =
350
+ reply(Result.failure(authenticationError(code, message)))
351
+ }
352
+
353
+ private fun authenticationError(code: Int, message: CharSequence) =
354
+ TokamakPluginError(
355
+ when (code) {
356
+ BiometricPrompt.BIOMETRIC_ERROR_CANCELED,
357
+ BiometricPrompt.BIOMETRIC_ERROR_USER_CANCELED,
358
+ BiometricPrompt.BIOMETRIC_ERROR_TIMEOUT,
359
+ BiometricPrompt.BIOMETRIC_ERROR_LOCKOUT,
360
+ BiometricPrompt.BIOMETRIC_ERROR_LOCKOUT_PERMANENT,
361
+ -> "NotAllowedError"
362
+ BiometricPrompt.BIOMETRIC_ERROR_NO_BIOMETRICS,
363
+ BiometricPrompt.BIOMETRIC_ERROR_NO_DEVICE_CREDENTIAL,
364
+ -> "InvalidStateError"
365
+ BiometricPrompt.BIOMETRIC_ERROR_HW_NOT_PRESENT -> "NotSupportedError"
366
+ else -> "OperationError"
367
+ },
368
+ message.toString(),
369
+ )
@@ -0,0 +1,221 @@
1
+ import Foundation
2
+ import LocalAuthentication
3
+ import Security
4
+
5
+ final class TokamakSecureStoragePlugin: TokamakPlugin {
6
+ let id = "secure-storage"
7
+
8
+ private let service = "tokamak.secure-storage"
9
+ /// Keychain calls block while the system authentication prompt is shown.
10
+ private let queue = DispatchQueue(label: "tokamak.secure-storage")
11
+
12
+ private static let teamSigningRequired = TokamakPluginError.notSupported(
13
+ "Secure storage requires a team-signed build"
14
+ )
15
+
16
+ #if os(macOS)
17
+ /// macOS grants the data protection keychain only to apps whose provisioning
18
+ /// profile provides an application identifier.
19
+ private static let hasApplicationIdentifier: Bool = {
20
+ guard let task = SecTaskCreateFromSelf(nil) else { return false }
21
+ let entitlement = "com.apple.application-identifier" as CFString
22
+ return SecTaskCopyValueForEntitlement(task, entitlement, nil) != nil
23
+ }()
24
+ #endif
25
+
26
+ func call(
27
+ method: String,
28
+ arguments: Any,
29
+ reply: @escaping TokamakPluginReply
30
+ ) {
31
+ let arguments = arguments as? [String: Any] ?? [:]
32
+ queue.async {
33
+ reply(Result { () throws(TokamakPluginError) in try self.perform(method, arguments) })
34
+ }
35
+ }
36
+
37
+ private func perform(
38
+ _ method: String,
39
+ _ arguments: [String: Any]
40
+ ) throws(TokamakPluginError) -> Any? {
41
+ #if os(macOS)
42
+ // Unentitled reads report errSecItemNotFound rather than a missing entitlement.
43
+ guard Self.hasApplicationIdentifier else { throw Self.teamSigningRequired }
44
+ #endif
45
+ switch method {
46
+ case "set":
47
+ try set(
48
+ requiredString(arguments, "name"),
49
+ value: requiredString(arguments, "value"),
50
+ readable: requiredString(arguments, "readable"),
51
+ authentication: optionalString(arguments, "authentication")
52
+ )
53
+ return nil
54
+ case "get":
55
+ return try get(
56
+ requiredString(arguments, "name"),
57
+ prompt: optionalString(arguments, "prompt")
58
+ )
59
+ case "delete":
60
+ try delete(requiredString(arguments, "name"))
61
+ return nil
62
+ default:
63
+ throw .notSupported("\(id).\(method) is not supported")
64
+ }
65
+ }
66
+
67
+ /// Replaces an existing value only once the new item can be added, so a
68
+ /// failed write keeps the old value.
69
+ private func set(
70
+ _ name: String,
71
+ value: String,
72
+ readable: String,
73
+ authentication: String?
74
+ ) throws(TokamakPluginError) {
75
+ var item = query(name)
76
+ item[kSecValueData] = Data(value.utf8)
77
+ let accessibility = try accessibility(readable)
78
+ if let authentication {
79
+ item[kSecAttrAccessControl] = try accessControl(accessibility, authentication)
80
+ } else {
81
+ item[kSecAttrAccessible] = accessibility
82
+ }
83
+ var status = SecItemAdd(item as CFDictionary, nil)
84
+ if status == errSecDuplicateItem {
85
+ try delete(name)
86
+ status = SecItemAdd(item as CFDictionary, nil)
87
+ }
88
+ try check(status)
89
+ }
90
+
91
+ private func get(_ name: String, prompt: String?) throws(TokamakPluginError) -> String? {
92
+ var item = query(name)
93
+ item[kSecReturnData] = true
94
+ if let prompt, !prompt.isEmpty {
95
+ let context = LAContext()
96
+ context.localizedReason = prompt
97
+ item[kSecUseAuthenticationContext] = context
98
+ }
99
+ var data: CFTypeRef?
100
+ let status = SecItemCopyMatching(item as CFDictionary, &data)
101
+ if status == errSecItemNotFound {
102
+ guard try exists(name) else { return nil }
103
+ throw TokamakPluginError(
104
+ name: "NotReadableError",
105
+ message: "The stored value can no longer be read"
106
+ )
107
+ }
108
+ try check(status)
109
+ guard let data = data as? Data else {
110
+ throw TokamakPluginError(name: "NotReadableError", message: "The stored value is not data")
111
+ }
112
+ return String(decoding: data, as: UTF8.self)
113
+ }
114
+
115
+ private func delete(_ name: String) throws(TokamakPluginError) {
116
+ let status = SecItemDelete(query(name) as CFDictionary)
117
+ if status != errSecItemNotFound {
118
+ try check(status)
119
+ }
120
+ }
121
+
122
+ /// Reads attributes only, which needs no authentication.
123
+ private func exists(_ name: String) throws(TokamakPluginError) -> Bool {
124
+ let context = LAContext()
125
+ context.interactionNotAllowed = true
126
+ var item = query(name)
127
+ item[kSecReturnAttributes] = true
128
+ item[kSecUseAuthenticationContext] = context
129
+ let status = SecItemCopyMatching(item as CFDictionary, nil)
130
+ if status == errSecItemNotFound {
131
+ return false
132
+ }
133
+ try check(status)
134
+ return true
135
+ }
136
+
137
+ /// The data protection keychain is the only keychain on iOS; macOS needs a
138
+ /// team-signed build to use it.
139
+ private func query(_ name: String) -> [CFString: Any] {
140
+ [
141
+ kSecClass: kSecClassGenericPassword,
142
+ kSecAttrService: service,
143
+ kSecAttrAccount: name,
144
+ kSecUseDataProtectionKeychain: true,
145
+ ]
146
+ }
147
+
148
+ private func accessibility(_ readable: String) throws(TokamakPluginError) -> CFString {
149
+ switch readable {
150
+ case "whenUnlocked": kSecAttrAccessibleWhenUnlockedThisDeviceOnly
151
+ case "afterFirstUnlock": kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
152
+ default: throw unsupportedArgument("readable", readable)
153
+ }
154
+ }
155
+
156
+ private func accessControl(
157
+ _ accessibility: CFString,
158
+ _ authentication: String
159
+ ) throws(TokamakPluginError) -> SecAccessControl {
160
+ let (flags, policy): (SecAccessControlCreateFlags, LAPolicy) =
161
+ switch authentication {
162
+ case "biometricsOrPasscode": (.userPresence, .deviceOwnerAuthentication)
163
+ case "biometrics": (.biometryAny, .deviceOwnerAuthenticationWithBiometrics)
164
+ case "currentBiometrics": (.biometryCurrentSet, .deviceOwnerAuthenticationWithBiometrics)
165
+ default: throw unsupportedArgument("authentication", authentication)
166
+ }
167
+ try requireSetUp(policy)
168
+ guard let control = SecAccessControlCreateWithFlags(nil, accessibility, flags, nil) else {
169
+ throw TokamakPluginError(name: "OperationError", message: "Access control is unavailable")
170
+ }
171
+ return control
172
+ }
173
+
174
+ private func requireSetUp(_ policy: LAPolicy) throws(TokamakPluginError) {
175
+ var error: NSError?
176
+ if LAContext().canEvaluatePolicy(policy, error: &error) {
177
+ return
178
+ }
179
+ let message = error?.localizedDescription ?? "Authentication is unavailable"
180
+ if (error as? LAError)?.code == .biometryNotAvailable {
181
+ throw .notSupported(message)
182
+ }
183
+ throw TokamakPluginError(name: "InvalidStateError", message: message)
184
+ }
185
+
186
+ private func check(_ status: OSStatus) throws(TokamakPluginError) {
187
+ guard status != errSecSuccess else { return }
188
+ let message = SecCopyErrorMessageString(status, nil) as String? ?? "Keychain error \(status)"
189
+ switch status {
190
+ case errSecMissingEntitlement:
191
+ throw Self.teamSigningRequired
192
+ case errSecUserCanceled, errSecAuthFailed, errSecInteractionNotAllowed:
193
+ throw TokamakPluginError(name: "NotAllowedError", message: message)
194
+ default:
195
+ throw TokamakPluginError(name: "OperationError", message: message)
196
+ }
197
+ }
198
+
199
+ private func requiredString(
200
+ _ arguments: [String: Any],
201
+ _ key: String
202
+ ) throws(TokamakPluginError) -> String {
203
+ guard let value = arguments[key] as? String else {
204
+ throw TokamakPluginError(name: "TypeError", message: "\(key) must be a string")
205
+ }
206
+ return value
207
+ }
208
+
209
+ /// Missing and null arguments are nil; other non-string values are rejected.
210
+ private func optionalString(
211
+ _ arguments: [String: Any],
212
+ _ key: String
213
+ ) throws(TokamakPluginError) -> String? {
214
+ guard let value = arguments[key], !(value is NSNull) else { return nil }
215
+ return try requiredString(arguments, key)
216
+ }
217
+
218
+ private func unsupportedArgument(_ key: String, _ value: String) -> TokamakPluginError {
219
+ TokamakPluginError(name: "TypeError", message: "\(key) '\(value)' is not supported")
220
+ }
221
+ }
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@tokamakdev/plugin-secure-storage",
3
+ "version": "0.1.0-beta.45",
4
+ "repository": {
5
+ "type": "git",
6
+ "url": "git+https://github.com/mantty/tokamak.git",
7
+ "directory": "plugins/secure-storage"
8
+ },
9
+ "type": "module",
10
+ "exports": {
11
+ ".": "./src/index.ts"
12
+ },
13
+ "dependencies": {
14
+ "@tokamakdev/plugin": "0.1.0-beta.45"
15
+ },
16
+ "files": [
17
+ "android",
18
+ "apple",
19
+ "src",
20
+ "tokamak-plugin.json",
21
+ "README.md"
22
+ ],
23
+ "publishConfig": {
24
+ "access": "public"
25
+ }
26
+ }
package/src/index.ts ADDED
@@ -0,0 +1,43 @@
1
+ import { FrontendPlugin } from "@tokamakdev/plugin";
2
+
3
+ /** When a stored value can be read. */
4
+ export type Readable = "whenUnlocked" | "afterFirstUnlock";
5
+
6
+ /** The authentication a read requires. */
7
+ export type Authentication = "biometricsOrPasscode" | "biometrics" | "currentBiometrics";
8
+
9
+ export interface SetOptions {
10
+ readonly readable: Readable;
11
+ /** Reads need no authentication when omitted. */
12
+ readonly authentication?: Authentication;
13
+ }
14
+
15
+ export interface GetOptions {
16
+ /** The reason shown in the system authentication prompt. */
17
+ readonly prompt?: string;
18
+ }
19
+
20
+ class SecureStorage extends FrontendPlugin {
21
+ constructor() {
22
+ super("secure-storage");
23
+ }
24
+
25
+ set(name: string, value: string, options: SetOptions): Promise<void> {
26
+ return this.call("set", {
27
+ name,
28
+ value,
29
+ readable: options.readable,
30
+ authentication: options.authentication ?? null,
31
+ });
32
+ }
33
+
34
+ get(name: string, options: GetOptions = {}): Promise<string | null> {
35
+ return this.call("get", { name, prompt: options.prompt ?? null });
36
+ }
37
+
38
+ delete(name: string): Promise<void> {
39
+ return this.call("delete", { name });
40
+ }
41
+ }
42
+
43
+ export const secureStorage = new SecureStorage();
@@ -0,0 +1,33 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "id": "secure-storage",
4
+ "kind": "frontend",
5
+ "platforms": {
6
+ "macos": {
7
+ "class": "TokamakSecureStoragePlugin",
8
+ "sources": ["apple/SecureStoragePlugin.swift"],
9
+ "frameworks": ["LocalAuthentication", "Security"]
10
+ },
11
+ "ios": {
12
+ "class": "TokamakSecureStoragePlugin",
13
+ "sources": ["apple/SecureStoragePlugin.swift"],
14
+ "frameworks": ["LocalAuthentication", "Security"],
15
+ "plist": {
16
+ "NSFaceIDUsageDescription": "This app uses Face ID to confirm your identity."
17
+ }
18
+ },
19
+ "ios-simulator": {
20
+ "class": "TokamakSecureStoragePlugin",
21
+ "sources": ["apple/SecureStoragePlugin.swift"],
22
+ "frameworks": ["LocalAuthentication", "Security"],
23
+ "plist": {
24
+ "NSFaceIDUsageDescription": "This app uses Face ID to confirm your identity."
25
+ }
26
+ },
27
+ "android": {
28
+ "class": "com.tokamak.plugins.securestorage.TokamakSecureStoragePlugin",
29
+ "sources": ["android/SecureStoragePlugin.kt"],
30
+ "permissions": ["android.permission.USE_BIOMETRIC"]
31
+ }
32
+ }
33
+ }