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

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,108 @@
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
+ await secureStorage.clear();
29
+ ```
30
+
31
+ Values are strings; encode binary values before storing them. Only the app can
32
+ read them, and they are never synced to other devices.
33
+
34
+ By default a backup restores a value only to the device that stored it. Set
35
+ `thisDeviceOnly: false` to let a backup restore it to a new device, where the
36
+ platform supports that.
37
+
38
+ `authentication` binds a value to the device's secure hardware, so it cannot be
39
+ decrypted until the device owner authenticates:
40
+
41
+ | `authentication` | Reads accept | After a biometric enrolment change |
42
+ |---|---|---|
43
+ | `biometricsOrPasscode` | Face ID, Touch ID or fingerprint; passcode, PIN, pattern or password | Readable |
44
+ | `biometrics` | Face ID, Touch ID or fingerprint | Readable |
45
+ | `currentBiometrics` | Face ID, Touch ID or fingerprint enrolled when the value was stored | Permanently unreadable |
46
+
47
+ On iOS, the system reports a read of a `currentBiometrics` value after an
48
+ enrolment change as failed authentication, so `get` rejects with
49
+ `NotAllowedError` rather than `NotReadableError`.
50
+
51
+ `prompt` is the reason shown in the system authentication prompt. Without it,
52
+ the platform's default prompt is shown.
53
+
54
+ `set` replaces an existing value in place or adds a new one, so a failed `set`
55
+ leaves the previous value. On Apple platforms the system asks for the
56
+ authentication an existing value was saved with before replacing it, as it does
57
+ for a read: `set` then shows its `prompt` and needs the app on screen. Android
58
+ replaces values without asking.
59
+
60
+ ## Errors
61
+
62
+ | Name | When |
63
+ |---|---|
64
+ | `NotSupportedError` | The platform, build or device cannot enforce the options |
65
+ | `InvalidStateError` | The requested authentication is not set up on the device |
66
+ | `NotAllowedError` | The user cancelled or failed authentication, or the device is locked |
67
+ | `NeedsUIError` | Reading a value saved with `authentication` while the app is off screen on iOS or Android, or replacing one on iOS, where the system cannot show its prompt |
68
+ | `NotReadableError` | A stored value can no longer be decrypted |
69
+ | `TypeError` | An argument is missing or has an unsupported value |
70
+ | `OperationError` | The platform reported another failure |
71
+
72
+ `get` resolves `null` when the name has no stored value.
73
+
74
+ ## Platforms
75
+
76
+ - **iOS:** Keychain items, which survive deleting and reinstalling the app.
77
+ They are included in encrypted backups; `thisDeviceOnly` values restore only
78
+ to the same device.
79
+ - **macOS:** the data protection keychain, which requires a team-signed build
80
+ (`macos.team-id`). Ad-hoc signed builds throw `NotSupportedError`.
81
+ - **Android:** values are encrypted with a per-value Android Keystore key and
82
+ stored in the app's no-backup directory. They are deleted on uninstall.
83
+ Keystore keys never leave the device, so `thisDeviceOnly: false` throws
84
+ `NotSupportedError`.
85
+ - **Web and Windows:** no implementation; calls throw `NotSupportedError`.
86
+
87
+ ### Values stored by builds from tok 0.1.1-beta.87 or earlier
88
+
89
+ Team-signed iOS and macOS builds made with tok 0.1.1-beta.87 or earlier kept
90
+ values in a keychain access group literally named `TEAMID.*`, which all of a
91
+ team's apps built that way share. Later builds use the app's own group,
92
+ `TEAMID.<bundle id>`, and cannot see those values. To keep them, declare both
93
+ groups, the app's own first, in the app's entitlements file
94
+ (`ios.entitlements`, `macos.entitlements`):
95
+
96
+ ```xml
97
+ <key>keychain-access-groups</key>
98
+ <array>
99
+ <string>TEAMID.com.example.app</string>
100
+ <string>TEAMID.*</string>
101
+ </array>
102
+ ```
103
+
104
+ New values go into the app's own group; existing values stay in the shared
105
+ group even when replaced, so keep `TEAMID.*` while users may still hold them.
106
+
107
+ Removing the device passcode or screen lock makes values stored with
108
+ `authentication` permanently unreadable on Android.
@@ -0,0 +1,3 @@
1
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android">
2
+ <uses-permission android:name="android.permission.USE_BIOMETRIC" />
3
+ </manifest>
@@ -0,0 +1,349 @@
1
+ package com.tokamak.plugins.securestorage
2
+
3
+ import android.hardware.biometrics.BiometricManager.Authenticators
4
+ import android.hardware.biometrics.BiometricPrompt
5
+ import android.security.keystore.KeyGenParameterSpec
6
+ import android.security.keystore.KeyInfo
7
+ import android.security.keystore.KeyPermanentlyInvalidatedException
8
+ import android.security.keystore.KeyProperties
9
+ import android.security.keystore.StrongBoxUnavailableException
10
+ import android.security.keystore.UserNotAuthenticatedException
11
+ import android.util.AtomicFile
12
+ import com.tokamak.runtime.TokamakHost
13
+ import com.tokamak.runtime.TokamakPlugin
14
+ import com.tokamak.runtime.TokamakPluginError
15
+ import com.tokamak.runtime.TokamakPluginReply
16
+ import com.tokamak.runtime.authenticateOwner
17
+ import com.tokamak.runtime.optionalString
18
+ import com.tokamak.runtime.requireBoolean
19
+ import com.tokamak.runtime.requireObject
20
+ import com.tokamak.runtime.requireOwnerAuthentication
21
+ import com.tokamak.runtime.requireString
22
+ import java.io.ByteArrayOutputStream
23
+ import java.io.DataInputStream
24
+ import java.io.DataOutputStream
25
+ import java.io.File
26
+ import java.io.FileNotFoundException
27
+ import java.security.KeyFactory
28
+ import java.security.KeyPairGenerator
29
+ import java.security.KeyStore
30
+ import java.security.MessageDigest
31
+ import java.security.PrivateKey
32
+ import java.security.PublicKey
33
+ import java.security.SecureRandom
34
+ import java.security.spec.MGF1ParameterSpec
35
+ import java.security.spec.X509EncodedKeySpec
36
+ import java.util.concurrent.Executors
37
+ import javax.crypto.BadPaddingException
38
+ import javax.crypto.Cipher
39
+ import javax.crypto.IllegalBlockSizeException
40
+ import javax.crypto.KeyGenerator
41
+ import javax.crypto.spec.GCMParameterSpec
42
+ import javax.crypto.spec.OAEPParameterSpec
43
+ import javax.crypto.spec.PSource
44
+ import javax.crypto.spec.SecretKeySpec
45
+ import org.json.JSONObject
46
+
47
+ /**
48
+ * Stores each value sealed with a random AES-GCM key, wrapped by an RSA key pair in the
49
+ * Android Keystore. Writing uses only the public key, so it never prompts; the private key
50
+ * carries the value's unlock and authentication requirements. Every write uses a new key
51
+ * pair, identified in the value's file, so a failed write leaves the previous value readable.
52
+ */
53
+ class TokamakSecureStoragePlugin(
54
+ private val host: TokamakHost,
55
+ ) : TokamakPlugin {
56
+ override val id = "secure-storage"
57
+
58
+ private val context = host.context
59
+ private val worker = Executors.newSingleThreadExecutor()
60
+ private val directory = File(context.noBackupFilesDir, "tokamak-secure-storage")
61
+ private val keyStore = KeyStore.getInstance(KEYSTORE).apply { load(null) }
62
+ private val random = SecureRandom()
63
+
64
+ override fun call(method: String, arguments: Any?, reply: TokamakPluginReply) {
65
+ when (method) {
66
+ "set" -> execute(reply) {
67
+ set(requireObject(arguments))
68
+ reply(Result.success(null))
69
+ }
70
+ "get" -> execute(reply) { get(requireObject(arguments), reply) }
71
+ "delete" -> execute(reply) {
72
+ delete(requireObject(arguments).requireString("name"))
73
+ reply(Result.success(null))
74
+ }
75
+ "clear" -> execute(reply) {
76
+ clear()
77
+ reply(Result.success(null))
78
+ }
79
+ else -> super.call(method, arguments, reply)
80
+ }
81
+ }
82
+
83
+ private fun execute(reply: TokamakPluginReply, operation: () -> Unit) =
84
+ worker.execute {
85
+ runCatching(operation).onFailure { reply(Result.failure(pluginError(it))) }
86
+ }
87
+
88
+ private fun onUiThread(reply: TokamakPluginReply, operation: () -> Unit) =
89
+ context.mainExecutor.execute {
90
+ runCatching(operation).onFailure { reply(Result.failure(pluginError(it))) }
91
+ }
92
+
93
+ private fun set(request: JSONObject) {
94
+ val name = request.requireString("name")
95
+ val value = request.requireString("value")
96
+ val unlockedDeviceRequired =
97
+ when (request.requireString("readable")) {
98
+ "whenUnlocked" -> true
99
+ "afterFirstUnlock" -> false
100
+ else -> throw TokamakPluginError.typeError("readable must be \"whenUnlocked\" or \"afterFirstUnlock\"")
101
+ }
102
+ if (!request.requireBoolean("thisDeviceOnly")) {
103
+ throw TokamakPluginError.notSupported("Android keeps secure storage values on this device")
104
+ }
105
+ val keyId = ByteArray(KEY_ID_SIZE).also(random::nextBytes)
106
+ val spec =
107
+ KeyGenParameterSpec.Builder(alias(keyId), KeyProperties.PURPOSE_DECRYPT)
108
+ .setKeySize(KEY_SIZE)
109
+ .setDigests(KeyProperties.DIGEST_SHA256)
110
+ .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_RSA_OAEP)
111
+ .setUnlockedDeviceRequired(unlockedDeviceRequired)
112
+ request.optionalString("authentication")?.let { requireAuthentication(spec, it) }
113
+ val file = file(name)
114
+ val previousAlias = read(file)?.let { alias(it.keyId) }
115
+ val publicKey = generatePublicKey(spec)
116
+ try {
117
+ write(file, seal(keyId, value, publicKey).encode())
118
+ } catch (error: Throwable) {
119
+ keyStore.deleteEntry(alias(keyId))
120
+ throw error
121
+ }
122
+ previousAlias?.let(keyStore::deleteEntry)
123
+ }
124
+
125
+ private fun requireAuthentication(spec: KeyGenParameterSpec.Builder, wireName: String) {
126
+ val authentication =
127
+ Authentication.entries.firstOrNull { it.wireName == wireName } ?: throw unknownAuthentication()
128
+ host.requireOwnerAuthentication(authentication.authenticators)
129
+ spec.setUserAuthenticationRequired(true)
130
+ .setUserAuthenticationParameters(0, authentication.keyTypes)
131
+ .setInvalidatedByBiometricEnrollment(authentication.invalidatedByEnrollment)
132
+ }
133
+
134
+ private fun generatePublicKey(spec: KeyGenParameterSpec.Builder): PublicKey {
135
+ val generator = KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_RSA, KEYSTORE)
136
+ val keyPair =
137
+ try {
138
+ generator.initialize(spec.setIsStrongBoxBacked(true).build())
139
+ generator.generateKeyPair()
140
+ } catch (_: StrongBoxUnavailableException) {
141
+ generator.initialize(spec.setIsStrongBoxBacked(false).build())
142
+ generator.generateKeyPair()
143
+ }
144
+ // A software copy of the public key encrypts without Keystore restrictions.
145
+ return KeyFactory.getInstance(KeyProperties.KEY_ALGORITHM_RSA)
146
+ .generatePublic(X509EncodedKeySpec(keyPair.public.encoded))
147
+ }
148
+
149
+ private fun seal(keyId: ByteArray, value: String, publicKey: PublicKey): StoredValue {
150
+ val dataKey = KeyGenerator.getInstance("AES").apply { init(DATA_KEY_SIZE) }.generateKey()
151
+ val iv = ByteArray(IV_SIZE).also(random::nextBytes)
152
+ val sealed =
153
+ Cipher.getInstance(SEAL)
154
+ .apply { init(Cipher.ENCRYPT_MODE, dataKey, GCMParameterSpec(TAG_SIZE, iv)) }
155
+ .doFinal(value.toByteArray())
156
+ val wrapped =
157
+ Cipher.getInstance(WRAP)
158
+ .apply { init(Cipher.ENCRYPT_MODE, publicKey, OAEP) }
159
+ .doFinal(dataKey.encoded)
160
+ return StoredValue(keyId, wrapped, iv, sealed)
161
+ }
162
+
163
+ private fun get(request: JSONObject, reply: TokamakPluginReply) {
164
+ val name = request.requireString("name")
165
+ val prompt = request.optionalString("prompt")
166
+ val stored = read(file(name))
167
+ if (stored == null) {
168
+ reply(Result.success(null))
169
+ return
170
+ }
171
+ val privateKey =
172
+ keyStore.getKey(alias(stored.keyId), null) as? PrivateKey
173
+ ?: throw notReadable()
174
+ val unwrap = Cipher.getInstance(WRAP).apply { init(Cipher.DECRYPT_MODE, privateKey, OAEP) }
175
+ val info =
176
+ KeyFactory.getInstance(privateKey.algorithm, KEYSTORE)
177
+ .getKeySpec(privateKey, KeyInfo::class.java)
178
+ if (!info.isUserAuthenticationRequired) {
179
+ reply(Result.success(open(stored, unwrap)))
180
+ return
181
+ }
182
+ val credential = (info.userAuthenticationType and KeyProperties.AUTH_DEVICE_CREDENTIAL) != 0
183
+ val authenticators =
184
+ if (credential) Authenticators.BIOMETRIC_STRONG or Authenticators.DEVICE_CREDENTIAL
185
+ else Authenticators.BIOMETRIC_STRONG
186
+ authenticate(authenticators, prompt, unwrap, stored, reply)
187
+ }
188
+
189
+ /** Authorises [unwrap] with the device owner's authentication, then replies with the opened value. */
190
+ private fun authenticate(
191
+ authenticators: Int,
192
+ prompt: String?,
193
+ unwrap: Cipher,
194
+ stored: StoredValue,
195
+ reply: TokamakPluginReply,
196
+ ) {
197
+ val title = prompt?.takeIf { it.isNotEmpty() } ?: context.applicationInfo.loadLabel(context.packageManager)
198
+ onUiThread(reply) {
199
+ host.authenticateOwner(title, authenticators, BiometricPrompt.CryptoObject(unwrap), reply) {
200
+ execute(reply) { reply(Result.success(open(stored, unwrap))) }
201
+ }
202
+ }
203
+ }
204
+
205
+ private fun open(stored: StoredValue, unwrap: Cipher): String {
206
+ val dataKey = SecretKeySpec(unwrap.doFinal(stored.wrappedKey), "AES")
207
+ return Cipher.getInstance(SEAL)
208
+ .apply { init(Cipher.DECRYPT_MODE, dataKey, GCMParameterSpec(TAG_SIZE, stored.iv)) }
209
+ .doFinal(stored.sealed)
210
+ .decodeToString()
211
+ }
212
+
213
+ private fun delete(name: String) {
214
+ val file = file(name)
215
+ val stored = read(file) ?: return
216
+ AtomicFile(file).delete()
217
+ keyStore.deleteEntry(alias(stored.keyId))
218
+ }
219
+
220
+ private fun clear() {
221
+ directory.deleteRecursively()
222
+ keyStore.aliases().toList()
223
+ .filter { it.startsWith(ALIAS_PREFIX) }
224
+ .forEach(keyStore::deleteEntry)
225
+ }
226
+
227
+ private fun read(file: File): StoredValue? =
228
+ try {
229
+ StoredValue.decode(AtomicFile(file).readFully())
230
+ } catch (_: FileNotFoundException) {
231
+ null
232
+ }
233
+
234
+ private fun write(file: File, bytes: ByteArray) {
235
+ directory.mkdirs()
236
+ val atomic = AtomicFile(file)
237
+ val output = atomic.startWrite()
238
+ try {
239
+ output.write(bytes)
240
+ atomic.finishWrite(output)
241
+ } catch (error: Throwable) {
242
+ atomic.failWrite(output)
243
+ throw error
244
+ }
245
+ }
246
+
247
+ private fun file(name: String) =
248
+ File(directory, MessageDigest.getInstance("SHA-256").digest(name.toByteArray()).toHex())
249
+
250
+ private fun alias(keyId: ByteArray) = ALIAS_PREFIX + keyId.toHex()
251
+
252
+ private fun ByteArray.toHex() = joinToString("") { "%02x".format(it) }
253
+
254
+ private enum class Authentication(
255
+ val wireName: String,
256
+ val keyTypes: Int,
257
+ val authenticators: Int,
258
+ val invalidatedByEnrollment: Boolean,
259
+ ) {
260
+ BIOMETRICS_OR_PASSCODE(
261
+ "biometricsOrPasscode",
262
+ KeyProperties.AUTH_BIOMETRIC_STRONG or KeyProperties.AUTH_DEVICE_CREDENTIAL,
263
+ Authenticators.BIOMETRIC_STRONG or Authenticators.DEVICE_CREDENTIAL,
264
+ invalidatedByEnrollment = false,
265
+ ),
266
+ BIOMETRICS(
267
+ "biometrics",
268
+ KeyProperties.AUTH_BIOMETRIC_STRONG,
269
+ Authenticators.BIOMETRIC_STRONG,
270
+ invalidatedByEnrollment = false,
271
+ ),
272
+ CURRENT_BIOMETRICS(
273
+ "currentBiometrics",
274
+ KeyProperties.AUTH_BIOMETRIC_STRONG,
275
+ Authenticators.BIOMETRIC_STRONG,
276
+ invalidatedByEnrollment = true,
277
+ ),
278
+ }
279
+
280
+ private companion object {
281
+ const val KEYSTORE = "AndroidKeyStore"
282
+ const val ALIAS_PREFIX = "tokamak-secure-storage-"
283
+ const val DATA_KEY_SIZE = 256
284
+ const val TAG_SIZE = 128
285
+ const val WRAP = "RSA/ECB/OAEPWithSHA-256AndMGF1Padding"
286
+ const val SEAL = "AES/GCM/NoPadding"
287
+
288
+ // Keystore RSA keys default to SHA-1 for the OAEP MGF1 digest.
289
+ val OAEP = OAEPParameterSpec("SHA-256", "MGF1", MGF1ParameterSpec.SHA1, PSource.PSpecified.DEFAULT)
290
+
291
+ fun unknownAuthentication() =
292
+ TokamakPluginError.typeError("authentication must be \"biometricsOrPasscode\", \"biometrics\" or \"currentBiometrics\"")
293
+
294
+ fun locked() = TokamakPluginError("NotAllowedError", "The device must be unlocked to read this value")
295
+
296
+ fun notReadable() =
297
+ TokamakPluginError("NotReadableError", "The stored value can no longer be decrypted")
298
+
299
+ fun pluginError(error: Throwable): Throwable =
300
+ when (error) {
301
+ is KeyPermanentlyInvalidatedException,
302
+ is BadPaddingException,
303
+ is IllegalBlockSizeException,
304
+ -> notReadable()
305
+ is UserNotAuthenticatedException -> locked()
306
+ else -> error
307
+ }
308
+ }
309
+ }
310
+
311
+ private const val KEY_SIZE = 2048
312
+ private const val KEY_ID_SIZE = 16
313
+ private const val WRAPPED_KEY_SIZE = KEY_SIZE / 8
314
+ private const val IV_SIZE = 12
315
+
316
+ /** A value's file: an unused string, then the key ID, wrapped data key, IV and sealed value. */
317
+ private class StoredValue(
318
+ val keyId: ByteArray,
319
+ val wrappedKey: ByteArray,
320
+ val iv: ByteArray,
321
+ val sealed: ByteArray,
322
+ ) {
323
+ fun encode(): ByteArray {
324
+ val bytes = ByteArrayOutputStream()
325
+ DataOutputStream(bytes).run {
326
+ writeUTF("")
327
+ write(keyId)
328
+ write(wrappedKey)
329
+ write(iv)
330
+ write(sealed)
331
+ }
332
+ return bytes.toByteArray()
333
+ }
334
+
335
+ companion object {
336
+ fun decode(bytes: ByteArray): StoredValue =
337
+ DataInputStream(bytes.inputStream()).run {
338
+ readUTF()
339
+ StoredValue(
340
+ keyId = readExactly(KEY_ID_SIZE),
341
+ wrappedKey = readExactly(WRAPPED_KEY_SIZE),
342
+ iv = readExactly(IV_SIZE),
343
+ sealed = readBytes(),
344
+ )
345
+ }
346
+
347
+ private fun DataInputStream.readExactly(size: Int) = ByteArray(size).also(::readFully)
348
+ }
349
+ }
@@ -0,0 +1,8 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>NSFaceIDUsageDescription</key>
6
+ <string>This app uses Face ID to confirm your identity.</string>
7
+ </dict>
8
+ </plist>
@@ -0,0 +1,299 @@
1
+ import Foundation
2
+ import LocalAuthentication
3
+ import Security
4
+
5
+ #if os(iOS)
6
+ import UIKit
7
+ #endif
8
+
9
+ final class TokamakSecureStoragePlugin: TokamakPlugin {
10
+ let id = "secure-storage"
11
+
12
+ private let service = "tokamak.secure-storage"
13
+ /// Keychain calls block while the system authentication prompt is shown.
14
+ private let queue = DispatchQueue(label: "tokamak.secure-storage")
15
+ private let host: TokamakHost
16
+
17
+ init(host: TokamakHost) {
18
+ self.host = host
19
+ }
20
+
21
+ private static let teamSigningRequired = TokamakPluginError.notSupported(
22
+ "Secure storage requires a team-signed build"
23
+ )
24
+
25
+ #if os(macOS)
26
+ /// macOS grants the data protection keychain only to apps whose provisioning
27
+ /// profile provides an application identifier.
28
+ private static let hasApplicationIdentifier: Bool = {
29
+ guard let task = SecTaskCreateFromSelf(nil) else { return false }
30
+ let entitlement = "com.apple.application-identifier" as CFString
31
+ return SecTaskCopyValueForEntitlement(task, entitlement, nil) != nil
32
+ }()
33
+ #endif
34
+
35
+ func call(
36
+ method: String,
37
+ arguments: Any,
38
+ reply: @escaping TokamakPluginReply
39
+ ) {
40
+ let arguments = arguments as? [String: Any] ?? [:]
41
+ let prompting =
42
+ isUnlocked ? Result { () throws(TokamakPluginError) in try host.requireUI() } : nil
43
+ queue.async {
44
+ reply(
45
+ Result { () throws(TokamakPluginError) in
46
+ try self.perform(method, arguments, prompting: prompting)
47
+ })
48
+ }
49
+ }
50
+
51
+ /// Whether the device's protected data is available, which it is not while
52
+ /// an iPhone is locked. Read it on the main thread.
53
+ private var isUnlocked: Bool {
54
+ #if os(iOS)
55
+ UIApplication.shared.isProtectedDataAvailable
56
+ #else
57
+ true
58
+ #endif
59
+ }
60
+
61
+ /// `prompting` is whether a read or update that needs authentication may
62
+ /// show the system prompt, decided on the main thread as the call arrives:
63
+ /// nil while the device is locked, where either fails without UI anyway.
64
+ private func perform(
65
+ _ method: String,
66
+ _ arguments: [String: Any],
67
+ prompting: Result<Void, TokamakPluginError>?
68
+ ) throws(TokamakPluginError) -> Any? {
69
+ #if os(macOS)
70
+ // Unentitled reads report errSecItemNotFound rather than a missing entitlement.
71
+ guard Self.hasApplicationIdentifier else { throw Self.teamSigningRequired }
72
+ #endif
73
+ switch method {
74
+ case "set":
75
+ try set(
76
+ requiredString(arguments, "name"),
77
+ value: requiredString(arguments, "value"),
78
+ readable: requiredString(arguments, "readable"),
79
+ thisDeviceOnly: requiredBool(arguments, "thisDeviceOnly"),
80
+ authentication: optionalString(arguments, "authentication"),
81
+ prompt: optionalString(arguments, "prompt"),
82
+ prompting: prompting
83
+ )
84
+ return nil
85
+ case "get":
86
+ return try get(
87
+ requiredString(arguments, "name"),
88
+ prompt: optionalString(arguments, "prompt"),
89
+ prompting: prompting
90
+ )
91
+ case "delete":
92
+ try deleteItems(query(requiredString(arguments, "name")))
93
+ return nil
94
+ case "clear":
95
+ try deleteItems(serviceQuery())
96
+ return nil
97
+ default:
98
+ throw .notSupported("\(id).\(method) is not supported")
99
+ }
100
+ }
101
+
102
+ /// Updates an existing value in place or adds a new one, so a failure
103
+ /// leaves the previous value. Checks without UI first, so only replacing a
104
+ /// value saved with authentication needs the system prompt.
105
+ private func set(
106
+ _ name: String,
107
+ value: String,
108
+ readable: String,
109
+ thisDeviceOnly: Bool,
110
+ authentication: String?,
111
+ prompt: String?,
112
+ prompting: Result<Void, TokamakPluginError>?
113
+ ) throws(TokamakPluginError) {
114
+ let accessibility = try accessibility(readable, thisDeviceOnly: thisDeviceOnly)
115
+ // Access control even without authentication, so an update removes any the existing value had.
116
+ let stored: [CFString: Any] = [
117
+ kSecValueData: Data(value.utf8),
118
+ kSecAttrAccessControl: try accessControl(accessibility, authentication),
119
+ ]
120
+ var existing = query(name)
121
+ existing[kSecUseAuthenticationContext] = Self.contextWithoutUI()
122
+ let status = SecItemCopyMatching(existing as CFDictionary, nil)
123
+ if status == errSecItemNotFound {
124
+ try check(SecItemAdd(query(name).merging(stored) { _, new in new } as CFDictionary, nil))
125
+ return
126
+ }
127
+ if status == errSecInteractionNotAllowed, let prompting {
128
+ try prompting.get()
129
+ existing[kSecUseAuthenticationContext] = Self.context(prompt: prompt)
130
+ } else {
131
+ try check(status)
132
+ }
133
+ try check(SecItemUpdate(existing as CFDictionary, stored as CFDictionary))
134
+ }
135
+
136
+ /// Reads without UI first, so only a value saved with authentication needs
137
+ /// the app able to show the system prompt.
138
+ private func get(
139
+ _ name: String,
140
+ prompt: String?,
141
+ prompting: Result<Void, TokamakPluginError>?
142
+ ) throws(TokamakPluginError) -> String? {
143
+ var item = query(name)
144
+ item[kSecReturnData] = true
145
+ item[kSecUseAuthenticationContext] = Self.contextWithoutUI()
146
+ var data: CFTypeRef?
147
+ var status = SecItemCopyMatching(item as CFDictionary, &data)
148
+ if status == errSecItemNotFound {
149
+ return nil
150
+ }
151
+ if status == errSecInteractionNotAllowed, let prompting {
152
+ try prompting.get()
153
+ item[kSecUseAuthenticationContext] = Self.context(prompt: prompt)
154
+ status = SecItemCopyMatching(item as CFDictionary, &data)
155
+ // The first read found the value, so it exists but the system can no longer decrypt it.
156
+ if status == errSecItemNotFound {
157
+ throw TokamakPluginError(
158
+ name: "NotReadableError",
159
+ message: "The stored value can no longer be read"
160
+ )
161
+ }
162
+ }
163
+ try check(status)
164
+ guard let data = data as? Data else {
165
+ throw TokamakPluginError(name: "NotReadableError", message: "The stored value is not data")
166
+ }
167
+ return String(decoding: data, as: UTF8.self)
168
+ }
169
+
170
+ private func deleteItems(_ query: [CFString: Any]) throws(TokamakPluginError) {
171
+ let status = SecItemDelete(query as CFDictionary)
172
+ if status != errSecItemNotFound {
173
+ try check(status)
174
+ }
175
+ }
176
+
177
+ /// A context in which reads that need authentication fail with
178
+ /// `errSecInteractionNotAllowed` rather than show the system prompt.
179
+ private static func contextWithoutUI() -> LAContext {
180
+ let context = LAContext()
181
+ context.interactionNotAllowed = true
182
+ return context
183
+ }
184
+
185
+ /// A context whose system prompt shows `prompt`, or nil for the default prompt.
186
+ private static func context(prompt: String?) -> LAContext? {
187
+ guard let prompt, !prompt.isEmpty else { return nil }
188
+ let context = LAContext()
189
+ context.localizedReason = prompt
190
+ return context
191
+ }
192
+
193
+ /// All of the plugin's items. The data protection keychain is the only
194
+ /// keychain on iOS; macOS needs a team-signed build to use it.
195
+ private func serviceQuery() -> [CFString: Any] {
196
+ [
197
+ kSecClass: kSecClassGenericPassword,
198
+ kSecAttrService: service,
199
+ kSecUseDataProtectionKeychain: true,
200
+ ]
201
+ }
202
+
203
+ private func query(_ name: String) -> [CFString: Any] {
204
+ serviceQuery().merging([kSecAttrAccount: name]) { _, name in name }
205
+ }
206
+
207
+ /// `ThisDeviceOnly` items restore only to the device they were stored on.
208
+ private func accessibility(
209
+ _ readable: String,
210
+ thisDeviceOnly: Bool
211
+ ) throws(TokamakPluginError) -> CFString {
212
+ switch (readable, thisDeviceOnly) {
213
+ case ("whenUnlocked", true): kSecAttrAccessibleWhenUnlockedThisDeviceOnly
214
+ case ("whenUnlocked", false): kSecAttrAccessibleWhenUnlocked
215
+ case ("afterFirstUnlock", true): kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
216
+ case ("afterFirstUnlock", false): kSecAttrAccessibleAfterFirstUnlock
217
+ default: throw unsupportedArgument("readable", readable)
218
+ }
219
+ }
220
+
221
+ private func accessControl(
222
+ _ accessibility: CFString,
223
+ _ authentication: String?
224
+ ) throws(TokamakPluginError) -> SecAccessControl {
225
+ let (flags, policy): (SecAccessControlCreateFlags, LAPolicy?) =
226
+ switch authentication {
227
+ case nil: ([], nil)
228
+ case "biometricsOrPasscode": (.userPresence, .deviceOwnerAuthentication)
229
+ case "biometrics": (.biometryAny, .deviceOwnerAuthenticationWithBiometrics)
230
+ case "currentBiometrics": (.biometryCurrentSet, .deviceOwnerAuthenticationWithBiometrics)
231
+ case let authentication?: throw unsupportedArgument("authentication", authentication)
232
+ }
233
+ if let policy {
234
+ try requireSetUp(policy)
235
+ }
236
+ guard let control = SecAccessControlCreateWithFlags(nil, accessibility, flags, nil) else {
237
+ throw TokamakPluginError(name: "OperationError", message: "Access control is unavailable")
238
+ }
239
+ return control
240
+ }
241
+
242
+ private func requireSetUp(_ policy: LAPolicy) throws(TokamakPluginError) {
243
+ var error: NSError?
244
+ if LAContext().canEvaluatePolicy(policy, error: &error) {
245
+ return
246
+ }
247
+ let message = error?.localizedDescription ?? "Authentication is unavailable"
248
+ if (error as? LAError)?.code == .biometryNotAvailable {
249
+ throw .notSupported(message)
250
+ }
251
+ throw TokamakPluginError(name: "InvalidStateError", message: message)
252
+ }
253
+
254
+ private func check(_ status: OSStatus) throws(TokamakPluginError) {
255
+ guard status != errSecSuccess else { return }
256
+ let message = SecCopyErrorMessageString(status, nil) as String? ?? "Keychain error \(status)"
257
+ switch status {
258
+ case errSecMissingEntitlement:
259
+ throw Self.teamSigningRequired
260
+ case errSecUserCanceled, errSecAuthFailed, errSecInteractionNotAllowed:
261
+ throw TokamakPluginError(name: "NotAllowedError", message: message)
262
+ default:
263
+ throw TokamakPluginError(name: "OperationError", message: message)
264
+ }
265
+ }
266
+
267
+ private func requiredString(
268
+ _ arguments: [String: Any],
269
+ _ key: String
270
+ ) throws(TokamakPluginError) -> String {
271
+ guard let value = arguments[key] as? String else {
272
+ throw TokamakPluginError(name: "TypeError", message: "\(key) must be a string")
273
+ }
274
+ return value
275
+ }
276
+
277
+ private func requiredBool(
278
+ _ arguments: [String: Any],
279
+ _ key: String
280
+ ) throws(TokamakPluginError) -> Bool {
281
+ guard let value = arguments[key] as? Bool else {
282
+ throw TokamakPluginError(name: "TypeError", message: "\(key) must be a boolean")
283
+ }
284
+ return value
285
+ }
286
+
287
+ /// Missing and null arguments are nil; other non-string values are rejected.
288
+ private func optionalString(
289
+ _ arguments: [String: Any],
290
+ _ key: String
291
+ ) throws(TokamakPluginError) -> String? {
292
+ guard let value = arguments[key], !(value is NSNull) else { return nil }
293
+ return try requiredString(arguments, key)
294
+ }
295
+
296
+ private func unsupportedArgument(_ key: String, _ value: String) -> TokamakPluginError {
297
+ TokamakPluginError(name: "TypeError", message: "\(key) '\(value)' is not supported")
298
+ }
299
+ }
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@tokamakdev/plugin-secure-storage",
3
+ "version": "0.1.0-beta.100",
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
+ "peerDependencies": {
14
+ "@tokamakdev/plugin": "0.1.0-beta.100"
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,60 @@
1
+ import { Plugin } 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
+ * Whether backups can restore the value only to this device. Defaults to true;
15
+ * false lets a backup restore it to a new device where the platform supports that.
16
+ */
17
+ readonly thisDeviceOnly?: boolean;
18
+ /**
19
+ * The reason shown in the system authentication prompt. Apple platforms ask for the
20
+ * authentication an existing value was saved with before replacing it.
21
+ */
22
+ readonly prompt?: string;
23
+ }
24
+
25
+ export interface GetOptions {
26
+ /** The reason shown in the system authentication prompt. */
27
+ readonly prompt?: string;
28
+ }
29
+
30
+ class SecureStorage extends Plugin {
31
+ constructor() {
32
+ super("secure-storage");
33
+ }
34
+
35
+ set(name: string, value: string, options: SetOptions): Promise<void> {
36
+ return this.call("set", {
37
+ name,
38
+ value,
39
+ readable: options.readable,
40
+ authentication: options.authentication ?? null,
41
+ thisDeviceOnly: options.thisDeviceOnly ?? true,
42
+ prompt: options.prompt ?? null,
43
+ });
44
+ }
45
+
46
+ get(name: string, options: GetOptions = {}): Promise<string | null> {
47
+ return this.call("get", { name, prompt: options.prompt ?? null });
48
+ }
49
+
50
+ delete(name: string): Promise<void> {
51
+ return this.call("delete", { name });
52
+ }
53
+
54
+ /** Deletes every stored value. */
55
+ clear(): Promise<void> {
56
+ return this.call("clear");
57
+ }
58
+ }
59
+
60
+ export const secureStorage = new SecureStorage();
@@ -0,0 +1,20 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "id": "secure-storage",
4
+ "platforms": {
5
+ "macos": {
6
+ "class": "TokamakSecureStoragePlugin",
7
+ "sources": ["apple/SecureStoragePlugin.swift"]
8
+ },
9
+ "ios": {
10
+ "class": "TokamakSecureStoragePlugin",
11
+ "sources": ["apple/SecureStoragePlugin.swift"],
12
+ "plist": "apple/Info.plist"
13
+ },
14
+ "android": {
15
+ "class": "com.tokamak.plugins.securestorage.TokamakSecureStoragePlugin",
16
+ "sources": ["android/SecureStoragePlugin.kt"],
17
+ "manifest": "android/AndroidManifest.xml"
18
+ }
19
+ }
20
+ }