@tokamakdev/plugin-secure-storage 0.1.0-beta.47 → 0.1.0-beta.48
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 +15 -5
- package/android/SecureStoragePlugin.kt +85 -25
- package/apple/SecureStoragePlugin.swift +66 -18
- package/package.json +2 -2
- package/src/index.ts +16 -0
package/README.md
CHANGED
|
@@ -24,11 +24,19 @@ const value = await secureStorage.get("identity/encryption", {
|
|
|
24
24
|
});
|
|
25
25
|
|
|
26
26
|
await secureStorage.delete("identity/encryption");
|
|
27
|
+
|
|
28
|
+
// string[], sorted
|
|
29
|
+
const names = await secureStorage.keys();
|
|
30
|
+
|
|
31
|
+
await secureStorage.clear();
|
|
27
32
|
```
|
|
28
33
|
|
|
29
34
|
Values are strings; encode binary values before storing them. Only the app can
|
|
30
|
-
read them, and they are never
|
|
31
|
-
|
|
35
|
+
read them, and they are never synced to other devices.
|
|
36
|
+
|
|
37
|
+
By default a backup restores a value only to the device that stored it. Set
|
|
38
|
+
`thisDeviceOnly: false` to let a backup restore it to a new device, where the
|
|
39
|
+
platform supports that.
|
|
32
40
|
|
|
33
41
|
`authentication` binds a value to the device's secure hardware, so it cannot be
|
|
34
42
|
decrypted until the device owner authenticates:
|
|
@@ -57,13 +65,15 @@ the platform's default prompt is shown.
|
|
|
57
65
|
|
|
58
66
|
## Platforms
|
|
59
67
|
|
|
60
|
-
- **iOS:** Keychain items
|
|
61
|
-
in encrypted backups
|
|
62
|
-
|
|
68
|
+
- **iOS:** Keychain items, which survive deleting and reinstalling the app.
|
|
69
|
+
They are included in encrypted backups; `thisDeviceOnly` values restore only
|
|
70
|
+
to the same device.
|
|
63
71
|
- **macOS:** the data protection keychain, which requires a team-signed build
|
|
64
72
|
(`macos-team-id`). Ad-hoc signed builds throw `NotSupportedError`.
|
|
65
73
|
- **Android:** values are encrypted with a per-value Android Keystore key and
|
|
66
74
|
stored in the app's no-backup directory. They are deleted on uninstall.
|
|
75
|
+
Keystore keys never leave the device, so `thisDeviceOnly: false` throws
|
|
76
|
+
`NotSupportedError`.
|
|
67
77
|
- **Web and Windows:** no implementation; calls throw `NotSupportedError`.
|
|
68
78
|
|
|
69
79
|
Removing the device passcode or screen lock makes values stored with
|
|
@@ -15,6 +15,9 @@ import android.util.AtomicFile
|
|
|
15
15
|
import com.tokamak.runtime.TokamakPlugin
|
|
16
16
|
import com.tokamak.runtime.TokamakPluginError
|
|
17
17
|
import com.tokamak.runtime.TokamakPluginReply
|
|
18
|
+
import java.io.ByteArrayOutputStream
|
|
19
|
+
import java.io.DataInputStream
|
|
20
|
+
import java.io.DataOutputStream
|
|
18
21
|
import java.io.File
|
|
19
22
|
import java.io.FileNotFoundException
|
|
20
23
|
import java.security.KeyFactory
|
|
@@ -65,6 +68,11 @@ internal class TokamakSecureStoragePlugin(
|
|
|
65
68
|
delete(request(arguments).requireString("name"))
|
|
66
69
|
reply(Result.success(null))
|
|
67
70
|
}
|
|
71
|
+
"keys" -> execute(reply) { reply(Result.success(keys())) }
|
|
72
|
+
"clear" -> execute(reply) {
|
|
73
|
+
clear()
|
|
74
|
+
reply(Result.success(null))
|
|
75
|
+
}
|
|
68
76
|
else -> super.call(method, arguments, reply)
|
|
69
77
|
}
|
|
70
78
|
}
|
|
@@ -88,6 +96,9 @@ internal class TokamakSecureStoragePlugin(
|
|
|
88
96
|
"afterFirstUnlock" -> false
|
|
89
97
|
else -> throw typeError("readable must be \"whenUnlocked\" or \"afterFirstUnlock\"")
|
|
90
98
|
}
|
|
99
|
+
if (!request.requireBoolean("thisDeviceOnly")) {
|
|
100
|
+
throw TokamakPluginError.notSupported("Android keeps secure storage values on this device")
|
|
101
|
+
}
|
|
91
102
|
val keyId = ByteArray(KEY_ID_SIZE).also(random::nextBytes)
|
|
92
103
|
val spec =
|
|
93
104
|
KeyGenParameterSpec.Builder(alias(keyId), KeyProperties.PURPOSE_DECRYPT)
|
|
@@ -97,10 +108,10 @@ internal class TokamakSecureStoragePlugin(
|
|
|
97
108
|
.setUnlockedDeviceRequired(unlockedDeviceRequired)
|
|
98
109
|
request.optionalString("authentication")?.let { requireAuthentication(spec, it) }
|
|
99
110
|
val file = file(name)
|
|
100
|
-
val previousAlias = read(file)?.let(
|
|
111
|
+
val previousAlias = read(file)?.let { alias(it.keyId) }
|
|
101
112
|
val publicKey = generatePublicKey(spec)
|
|
102
113
|
try {
|
|
103
|
-
write(file, keyId
|
|
114
|
+
write(file, seal(name, keyId, value, publicKey).encode())
|
|
104
115
|
} catch (error: Throwable) {
|
|
105
116
|
keyStore.deleteEntry(alias(keyId))
|
|
106
117
|
throw error
|
|
@@ -141,7 +152,7 @@ internal class TokamakSecureStoragePlugin(
|
|
|
141
152
|
.generatePublic(X509EncodedKeySpec(keyPair.public.encoded))
|
|
142
153
|
}
|
|
143
154
|
|
|
144
|
-
private fun seal(value: String, publicKey: PublicKey):
|
|
155
|
+
private fun seal(name: String, keyId: ByteArray, value: String, publicKey: PublicKey): StoredValue {
|
|
145
156
|
val dataKey = KeyGenerator.getInstance("AES").apply { init(DATA_KEY_SIZE) }.generateKey()
|
|
146
157
|
val iv = ByteArray(IV_SIZE).also(random::nextBytes)
|
|
147
158
|
val sealed =
|
|
@@ -152,7 +163,7 @@ internal class TokamakSecureStoragePlugin(
|
|
|
152
163
|
Cipher.getInstance(WRAP)
|
|
153
164
|
.apply { init(Cipher.ENCRYPT_MODE, publicKey, OAEP) }
|
|
154
165
|
.doFinal(dataKey.encoded)
|
|
155
|
-
return wrapped
|
|
166
|
+
return StoredValue(name, keyId, wrapped, iv, sealed)
|
|
156
167
|
}
|
|
157
168
|
|
|
158
169
|
private fun get(request: JSONObject, reply: TokamakPluginReply) {
|
|
@@ -164,7 +175,7 @@ internal class TokamakSecureStoragePlugin(
|
|
|
164
175
|
return
|
|
165
176
|
}
|
|
166
177
|
val privateKey =
|
|
167
|
-
keyStore.getKey(
|
|
178
|
+
keyStore.getKey(alias(stored.keyId), null) as? PrivateKey
|
|
168
179
|
?: throw notReadable()
|
|
169
180
|
val unwrap = Cipher.getInstance(WRAP).apply { init(Cipher.DECRYPT_MODE, privateKey, OAEP) }
|
|
170
181
|
val info =
|
|
@@ -186,7 +197,7 @@ internal class TokamakSecureStoragePlugin(
|
|
|
186
197
|
authenticators: Int,
|
|
187
198
|
prompt: String?,
|
|
188
199
|
unwrap: Cipher,
|
|
189
|
-
stored:
|
|
200
|
+
stored: StoredValue,
|
|
190
201
|
reply: TokamakPluginReply,
|
|
191
202
|
) {
|
|
192
203
|
val callback = PromptCallback(reply) { execute(reply) { reply(Result.success(open(stored, unwrap))) } }
|
|
@@ -211,26 +222,38 @@ internal class TokamakSecureStoragePlugin(
|
|
|
211
222
|
}
|
|
212
223
|
}
|
|
213
224
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
val dataKey = SecretKeySpec(unwrap.doFinal(stored, KEY_ID_SIZE, WRAPPED_KEY_SIZE), "AES")
|
|
217
|
-
val iv = stored.copyOfRange(IV_START, SEALED_START)
|
|
225
|
+
private fun open(stored: StoredValue, unwrap: Cipher): String {
|
|
226
|
+
val dataKey = SecretKeySpec(unwrap.doFinal(stored.wrappedKey), "AES")
|
|
218
227
|
return Cipher.getInstance(SEAL)
|
|
219
|
-
.apply { init(Cipher.DECRYPT_MODE, dataKey, GCMParameterSpec(TAG_SIZE, iv)) }
|
|
220
|
-
.doFinal(stored
|
|
228
|
+
.apply { init(Cipher.DECRYPT_MODE, dataKey, GCMParameterSpec(TAG_SIZE, stored.iv)) }
|
|
229
|
+
.doFinal(stored.sealed)
|
|
221
230
|
.decodeToString()
|
|
222
231
|
}
|
|
223
232
|
|
|
224
233
|
private fun delete(name: String) {
|
|
225
234
|
val file = file(name)
|
|
226
|
-
val
|
|
235
|
+
val stored = read(file) ?: return
|
|
227
236
|
AtomicFile(file).delete()
|
|
228
|
-
keyStore.deleteEntry(alias)
|
|
237
|
+
keyStore.deleteEntry(alias(stored.keyId))
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** AtomicFile's in-progress and backup files carry a suffix; value files have none. */
|
|
241
|
+
private fun keys(): List<String> =
|
|
242
|
+
directory.listFiles { file -> '.' !in file.name }
|
|
243
|
+
.orEmpty()
|
|
244
|
+
.mapNotNull { read(it)?.name }
|
|
245
|
+
.sorted()
|
|
246
|
+
|
|
247
|
+
private fun clear() {
|
|
248
|
+
directory.deleteRecursively()
|
|
249
|
+
keyStore.aliases().toList()
|
|
250
|
+
.filter { it.startsWith(ALIAS_PREFIX) }
|
|
251
|
+
.forEach(keyStore::deleteEntry)
|
|
229
252
|
}
|
|
230
253
|
|
|
231
|
-
private fun read(file: File):
|
|
254
|
+
private fun read(file: File): StoredValue? =
|
|
232
255
|
try {
|
|
233
|
-
AtomicFile(file).readFully()
|
|
256
|
+
StoredValue.decode(AtomicFile(file).readFully())
|
|
234
257
|
} catch (_: FileNotFoundException) {
|
|
235
258
|
null
|
|
236
259
|
}
|
|
@@ -251,9 +274,7 @@ internal class TokamakSecureStoragePlugin(
|
|
|
251
274
|
private fun file(name: String) =
|
|
252
275
|
File(directory, MessageDigest.getInstance("SHA-256").digest(name.toByteArray()).toHex())
|
|
253
276
|
|
|
254
|
-
private fun alias(keyId: ByteArray) =
|
|
255
|
-
|
|
256
|
-
private fun storedAlias(stored: ByteArray) = alias(stored.copyOf(KEY_ID_SIZE))
|
|
277
|
+
private fun alias(keyId: ByteArray) = ALIAS_PREFIX + keyId.toHex()
|
|
257
278
|
|
|
258
279
|
private fun ByteArray.toHex() = joinToString("") { "%02x".format(it) }
|
|
259
280
|
|
|
@@ -263,6 +284,9 @@ internal class TokamakSecureStoragePlugin(
|
|
|
263
284
|
private fun JSONObject.requireString(name: String): String =
|
|
264
285
|
optionalString(name) ?: throw typeError("$name must be a string")
|
|
265
286
|
|
|
287
|
+
private fun JSONObject.requireBoolean(name: String): Boolean =
|
|
288
|
+
opt(name) as? Boolean ?: throw typeError("$name must be a boolean")
|
|
289
|
+
|
|
266
290
|
private fun JSONObject.optionalString(name: String): String? =
|
|
267
291
|
when (val value = opt(name)) {
|
|
268
292
|
null, JSONObject.NULL -> null
|
|
@@ -298,13 +322,8 @@ internal class TokamakSecureStoragePlugin(
|
|
|
298
322
|
|
|
299
323
|
private companion object {
|
|
300
324
|
const val KEYSTORE = "AndroidKeyStore"
|
|
301
|
-
const val
|
|
302
|
-
const val KEY_ID_SIZE = 16
|
|
303
|
-
const val WRAPPED_KEY_SIZE = KEY_SIZE / 8
|
|
325
|
+
const val ALIAS_PREFIX = "tokamak-secure-storage-"
|
|
304
326
|
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
327
|
const val TAG_SIZE = 128
|
|
309
328
|
const val WRAP = "RSA/ECB/OAEPWithSHA-256AndMGF1Padding"
|
|
310
329
|
const val SEAL = "AES/GCM/NoPadding"
|
|
@@ -340,6 +359,47 @@ internal class TokamakSecureStoragePlugin(
|
|
|
340
359
|
}
|
|
341
360
|
}
|
|
342
361
|
|
|
362
|
+
private const val KEY_SIZE = 2048
|
|
363
|
+
private const val KEY_ID_SIZE = 16
|
|
364
|
+
private const val WRAPPED_KEY_SIZE = KEY_SIZE / 8
|
|
365
|
+
private const val IV_SIZE = 12
|
|
366
|
+
|
|
367
|
+
/** A value's file: its name, key ID, wrapped data key, IV, then sealed value. */
|
|
368
|
+
private class StoredValue(
|
|
369
|
+
val name: String,
|
|
370
|
+
val keyId: ByteArray,
|
|
371
|
+
val wrappedKey: ByteArray,
|
|
372
|
+
val iv: ByteArray,
|
|
373
|
+
val sealed: ByteArray,
|
|
374
|
+
) {
|
|
375
|
+
fun encode(): ByteArray {
|
|
376
|
+
val bytes = ByteArrayOutputStream()
|
|
377
|
+
DataOutputStream(bytes).run {
|
|
378
|
+
writeUTF(name)
|
|
379
|
+
write(keyId)
|
|
380
|
+
write(wrappedKey)
|
|
381
|
+
write(iv)
|
|
382
|
+
write(sealed)
|
|
383
|
+
}
|
|
384
|
+
return bytes.toByteArray()
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
companion object {
|
|
388
|
+
fun decode(bytes: ByteArray): StoredValue =
|
|
389
|
+
DataInputStream(bytes.inputStream()).run {
|
|
390
|
+
StoredValue(
|
|
391
|
+
name = readUTF(),
|
|
392
|
+
keyId = readExactly(KEY_ID_SIZE),
|
|
393
|
+
wrappedKey = readExactly(WRAPPED_KEY_SIZE),
|
|
394
|
+
iv = readExactly(IV_SIZE),
|
|
395
|
+
sealed = readBytes(),
|
|
396
|
+
)
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
private fun DataInputStream.readExactly(size: Int) = ByteArray(size).also(::readFully)
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
343
403
|
private class PromptCallback(
|
|
344
404
|
private val reply: TokamakPluginReply,
|
|
345
405
|
private val succeeded: () -> Unit,
|
|
@@ -47,7 +47,10 @@ final class TokamakSecureStoragePlugin: TokamakPlugin {
|
|
|
47
47
|
try set(
|
|
48
48
|
requiredString(arguments, "name"),
|
|
49
49
|
value: requiredString(arguments, "value"),
|
|
50
|
-
|
|
50
|
+
accessibility: accessibility(
|
|
51
|
+
requiredString(arguments, "readable"),
|
|
52
|
+
thisDeviceOnly: requiredBool(arguments, "thisDeviceOnly")
|
|
53
|
+
),
|
|
51
54
|
authentication: optionalString(arguments, "authentication")
|
|
52
55
|
)
|
|
53
56
|
return nil
|
|
@@ -57,7 +60,12 @@ final class TokamakSecureStoragePlugin: TokamakPlugin {
|
|
|
57
60
|
prompt: optionalString(arguments, "prompt")
|
|
58
61
|
)
|
|
59
62
|
case "delete":
|
|
60
|
-
try
|
|
63
|
+
try deleteItems(query(requiredString(arguments, "name")))
|
|
64
|
+
return nil
|
|
65
|
+
case "keys":
|
|
66
|
+
return try keys()
|
|
67
|
+
case "clear":
|
|
68
|
+
try deleteItems(serviceQuery())
|
|
61
69
|
return nil
|
|
62
70
|
default:
|
|
63
71
|
throw .notSupported("\(id).\(method) is not supported")
|
|
@@ -69,12 +77,11 @@ final class TokamakSecureStoragePlugin: TokamakPlugin {
|
|
|
69
77
|
private func set(
|
|
70
78
|
_ name: String,
|
|
71
79
|
value: String,
|
|
72
|
-
|
|
80
|
+
accessibility: CFString,
|
|
73
81
|
authentication: String?
|
|
74
82
|
) throws(TokamakPluginError) {
|
|
75
83
|
var item = query(name)
|
|
76
84
|
item[kSecValueData] = Data(value.utf8)
|
|
77
|
-
let accessibility = try accessibility(readable)
|
|
78
85
|
if let authentication {
|
|
79
86
|
item[kSecAttrAccessControl] = try accessControl(accessibility, authentication)
|
|
80
87
|
} else {
|
|
@@ -82,7 +89,7 @@ final class TokamakSecureStoragePlugin: TokamakPlugin {
|
|
|
82
89
|
}
|
|
83
90
|
var status = SecItemAdd(item as CFDictionary, nil)
|
|
84
91
|
if status == errSecDuplicateItem {
|
|
85
|
-
try
|
|
92
|
+
try deleteItems(query(name))
|
|
86
93
|
status = SecItemAdd(item as CFDictionary, nil)
|
|
87
94
|
}
|
|
88
95
|
try check(status)
|
|
@@ -112,8 +119,26 @@ final class TokamakSecureStoragePlugin: TokamakPlugin {
|
|
|
112
119
|
return String(decoding: data, as: UTF8.self)
|
|
113
120
|
}
|
|
114
121
|
|
|
115
|
-
|
|
116
|
-
|
|
122
|
+
/// Reads attributes only, which needs no authentication.
|
|
123
|
+
private func keys() throws(TokamakPluginError) -> [String] {
|
|
124
|
+
var items = serviceQuery()
|
|
125
|
+
items[kSecMatchLimit] = kSecMatchLimitAll
|
|
126
|
+
items[kSecReturnAttributes] = true
|
|
127
|
+
items[kSecUseAuthenticationContext] = nonInteractiveContext()
|
|
128
|
+
var attributes: CFTypeRef?
|
|
129
|
+
let status = SecItemCopyMatching(items as CFDictionary, &attributes)
|
|
130
|
+
if status == errSecItemNotFound {
|
|
131
|
+
return []
|
|
132
|
+
}
|
|
133
|
+
try check(status)
|
|
134
|
+
let names = (attributes as? [[String: Any]] ?? []).compactMap {
|
|
135
|
+
$0[kSecAttrAccount as String] as? String
|
|
136
|
+
}
|
|
137
|
+
return names.sorted()
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private func deleteItems(_ query: [CFString: Any]) throws(TokamakPluginError) {
|
|
141
|
+
let status = SecItemDelete(query as CFDictionary)
|
|
117
142
|
if status != errSecItemNotFound {
|
|
118
143
|
try check(status)
|
|
119
144
|
}
|
|
@@ -121,11 +146,9 @@ final class TokamakSecureStoragePlugin: TokamakPlugin {
|
|
|
121
146
|
|
|
122
147
|
/// Reads attributes only, which needs no authentication.
|
|
123
148
|
private func exists(_ name: String) throws(TokamakPluginError) -> Bool {
|
|
124
|
-
let context = LAContext()
|
|
125
|
-
context.interactionNotAllowed = true
|
|
126
149
|
var item = query(name)
|
|
127
150
|
item[kSecReturnAttributes] = true
|
|
128
|
-
item[kSecUseAuthenticationContext] =
|
|
151
|
+
item[kSecUseAuthenticationContext] = nonInteractiveContext()
|
|
129
152
|
let status = SecItemCopyMatching(item as CFDictionary, nil)
|
|
130
153
|
if status == errSecItemNotFound {
|
|
131
154
|
return false
|
|
@@ -134,21 +157,36 @@ final class TokamakSecureStoragePlugin: TokamakPlugin {
|
|
|
134
157
|
return true
|
|
135
158
|
}
|
|
136
159
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
160
|
+
private func nonInteractiveContext() -> LAContext {
|
|
161
|
+
let context = LAContext()
|
|
162
|
+
context.interactionNotAllowed = true
|
|
163
|
+
return context
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/// All of the plugin's items. The data protection keychain is the only
|
|
167
|
+
/// keychain on iOS; macOS needs a team-signed build to use it.
|
|
168
|
+
private func serviceQuery() -> [CFString: Any] {
|
|
140
169
|
[
|
|
141
170
|
kSecClass: kSecClassGenericPassword,
|
|
142
171
|
kSecAttrService: service,
|
|
143
|
-
kSecAttrAccount: name,
|
|
144
172
|
kSecUseDataProtectionKeychain: true,
|
|
145
173
|
]
|
|
146
174
|
}
|
|
147
175
|
|
|
148
|
-
private func
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
176
|
+
private func query(_ name: String) -> [CFString: Any] {
|
|
177
|
+
serviceQuery().merging([kSecAttrAccount: name]) { _, name in name }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/// `ThisDeviceOnly` items restore only to the device they were stored on.
|
|
181
|
+
private func accessibility(
|
|
182
|
+
_ readable: String,
|
|
183
|
+
thisDeviceOnly: Bool
|
|
184
|
+
) throws(TokamakPluginError) -> CFString {
|
|
185
|
+
switch (readable, thisDeviceOnly) {
|
|
186
|
+
case ("whenUnlocked", true): kSecAttrAccessibleWhenUnlockedThisDeviceOnly
|
|
187
|
+
case ("whenUnlocked", false): kSecAttrAccessibleWhenUnlocked
|
|
188
|
+
case ("afterFirstUnlock", true): kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
|
189
|
+
case ("afterFirstUnlock", false): kSecAttrAccessibleAfterFirstUnlock
|
|
152
190
|
default: throw unsupportedArgument("readable", readable)
|
|
153
191
|
}
|
|
154
192
|
}
|
|
@@ -206,6 +244,16 @@ final class TokamakSecureStoragePlugin: TokamakPlugin {
|
|
|
206
244
|
return value
|
|
207
245
|
}
|
|
208
246
|
|
|
247
|
+
private func requiredBool(
|
|
248
|
+
_ arguments: [String: Any],
|
|
249
|
+
_ key: String
|
|
250
|
+
) throws(TokamakPluginError) -> Bool {
|
|
251
|
+
guard let value = arguments[key] as? Bool else {
|
|
252
|
+
throw TokamakPluginError(name: "TypeError", message: "\(key) must be a boolean")
|
|
253
|
+
}
|
|
254
|
+
return value
|
|
255
|
+
}
|
|
256
|
+
|
|
209
257
|
/// Missing and null arguments are nil; other non-string values are rejected.
|
|
210
258
|
private func optionalString(
|
|
211
259
|
_ arguments: [String: Any],
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokamakdev/plugin-secure-storage",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.48",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/mantty/tokamak.git",
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
".": "./src/index.ts"
|
|
12
12
|
},
|
|
13
13
|
"dependencies": {
|
|
14
|
-
"@tokamakdev/plugin": "0.1.0-beta.
|
|
14
|
+
"@tokamakdev/plugin": "0.1.0-beta.48"
|
|
15
15
|
},
|
|
16
16
|
"files": [
|
|
17
17
|
"android",
|
package/src/index.ts
CHANGED
|
@@ -10,6 +10,11 @@ export interface SetOptions {
|
|
|
10
10
|
readonly readable: Readable;
|
|
11
11
|
/** Reads need no authentication when omitted. */
|
|
12
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;
|
|
13
18
|
}
|
|
14
19
|
|
|
15
20
|
export interface GetOptions {
|
|
@@ -28,6 +33,7 @@ class SecureStorage extends FrontendPlugin {
|
|
|
28
33
|
value,
|
|
29
34
|
readable: options.readable,
|
|
30
35
|
authentication: options.authentication ?? null,
|
|
36
|
+
thisDeviceOnly: options.thisDeviceOnly ?? true,
|
|
31
37
|
});
|
|
32
38
|
}
|
|
33
39
|
|
|
@@ -38,6 +44,16 @@ class SecureStorage extends FrontendPlugin {
|
|
|
38
44
|
delete(name: string): Promise<void> {
|
|
39
45
|
return this.call("delete", { name });
|
|
40
46
|
}
|
|
47
|
+
|
|
48
|
+
/** The names of all stored values, sorted. */
|
|
49
|
+
keys(): Promise<string[]> {
|
|
50
|
+
return this.call("keys");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Deletes every stored value. */
|
|
54
|
+
clear(): Promise<void> {
|
|
55
|
+
return this.call("clear");
|
|
56
|
+
}
|
|
41
57
|
}
|
|
42
58
|
|
|
43
59
|
export const secureStorage = new SecureStorage();
|