@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 +70 -0
- package/android/SecureStoragePlugin.kt +369 -0
- package/apple/SecureStoragePlugin.swift +221 -0
- package/package.json +26 -0
- package/src/index.ts +43 -0
- package/tokamak-plugin.json +33 -0
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
|
+
}
|