@tokamakdev/plugin-secure-storage 0.1.0-beta.47 → 0.1.0-beta.49

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 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 included in synced credential stores or restored
31
- to another device.
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 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.
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
- (`macos-team-id`). Ad-hoc signed builds throw `NotSupportedError`.
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(::storedAlias)
111
+ val previousAlias = read(file)?.let { alias(it.keyId) }
101
112
  val publicKey = generatePublicKey(spec)
102
113
  try {
103
- write(file, keyId + seal(value, publicKey))
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): ByteArray {
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 + iv + sealed
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(storedAlias(stored), null) as? PrivateKey
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: ByteArray,
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
- /** 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)
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, SEALED_START, stored.size - SEALED_START)
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 alias = read(file)?.let(::storedAlias) ?: return
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): ByteArray? =
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) = "tokamak-secure-storage-${keyId.toHex()}"
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 KEY_SIZE = 2048
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
- readable: requiredString(arguments, "readable"),
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 delete(requiredString(arguments, "name"))
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
- readable: String,
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 delete(name)
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
- private func delete(_ name: String) throws(TokamakPluginError) {
116
- let status = SecItemDelete(query(name) as CFDictionary)
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] = context
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
- /// 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] {
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 accessibility(_ readable: String) throws(TokamakPluginError) -> CFString {
149
- switch readable {
150
- case "whenUnlocked": kSecAttrAccessibleWhenUnlockedThisDeviceOnly
151
- case "afterFirstUnlock": kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
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.47",
3
+ "version": "0.1.0-beta.49",
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.47"
14
+ "@tokamakdev/plugin": "0.1.0-beta.49"
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();