@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 +108 -0
- package/android/AndroidManifest.xml +3 -0
- package/android/SecureStoragePlugin.kt +349 -0
- package/apple/Info.plist +8 -0
- package/apple/SecureStoragePlugin.swift +299 -0
- package/package.json +26 -0
- package/src/index.ts +60 -0
- package/tokamak-plugin.json +20 -0
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,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
|
+
}
|
package/apple/Info.plist
ADDED
|
@@ -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
|
+
}
|