@tokamakdev/plugin-secure-storage 0.1.0-beta.96 → 0.1.0-beta.98

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
@@ -25,9 +25,6 @@ const value = await secureStorage.get("identity/encryption", {
25
25
 
26
26
  await secureStorage.delete("identity/encryption");
27
27
 
28
- // string[], sorted
29
- const names = await secureStorage.keys();
30
-
31
28
  await secureStorage.clear();
32
29
  ```
33
30
 
@@ -47,6 +44,10 @@ decrypted until the device owner authenticates:
47
44
  | `biometrics` | Face ID, Touch ID or fingerprint | Readable |
48
45
  | `currentBiometrics` | Face ID, Touch ID or fingerprint enrolled when the value was stored | Permanently unreadable |
49
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
+
50
51
  `prompt` is the reason shown in the system authentication prompt. Without it,
51
52
  the platform's default prompt is shown.
52
53
 
@@ -64,9 +65,6 @@ the platform's default prompt is shown.
64
65
 
65
66
  `get` resolves `null` when the name has no stored value.
66
67
 
67
- Names are not protected like values: `keys()` lists them whenever the device
68
- has been unlocked since it started, without authentication.
69
-
70
68
  ## Platforms
71
69
 
72
70
  - **iOS:** Keychain items, which survive deleting and reinstalling the app.
@@ -72,7 +72,6 @@ class TokamakSecureStoragePlugin(
72
72
  delete(requireObject(arguments).requireString("name"))
73
73
  reply(Result.success(null))
74
74
  }
75
- "keys" -> execute(reply) { reply(Result.success(keys())) }
76
75
  "clear" -> execute(reply) {
77
76
  clear()
78
77
  reply(Result.success(null))
@@ -115,7 +114,7 @@ class TokamakSecureStoragePlugin(
115
114
  val previousAlias = read(file)?.let { alias(it.keyId) }
116
115
  val publicKey = generatePublicKey(spec)
117
116
  try {
118
- write(file, seal(name, keyId, value, publicKey).encode())
117
+ write(file, seal(keyId, value, publicKey).encode())
119
118
  } catch (error: Throwable) {
120
119
  keyStore.deleteEntry(alias(keyId))
121
120
  throw error
@@ -147,7 +146,7 @@ class TokamakSecureStoragePlugin(
147
146
  .generatePublic(X509EncodedKeySpec(keyPair.public.encoded))
148
147
  }
149
148
 
150
- private fun seal(name: String, keyId: ByteArray, value: String, publicKey: PublicKey): StoredValue {
149
+ private fun seal(keyId: ByteArray, value: String, publicKey: PublicKey): StoredValue {
151
150
  val dataKey = KeyGenerator.getInstance("AES").apply { init(DATA_KEY_SIZE) }.generateKey()
152
151
  val iv = ByteArray(IV_SIZE).also(random::nextBytes)
153
152
  val sealed =
@@ -158,7 +157,7 @@ class TokamakSecureStoragePlugin(
158
157
  Cipher.getInstance(WRAP)
159
158
  .apply { init(Cipher.ENCRYPT_MODE, publicKey, OAEP) }
160
159
  .doFinal(dataKey.encoded)
161
- return StoredValue(name, keyId, wrapped, iv, sealed)
160
+ return StoredValue(keyId, wrapped, iv, sealed)
162
161
  }
163
162
 
164
163
  private fun get(request: JSONObject, reply: TokamakPluginReply) {
@@ -218,13 +217,6 @@ class TokamakSecureStoragePlugin(
218
217
  keyStore.deleteEntry(alias(stored.keyId))
219
218
  }
220
219
 
221
- /** AtomicFile's in-progress and backup files carry a suffix; value files have none. */
222
- private fun keys(): List<String> =
223
- directory.listFiles { file -> '.' !in file.name }
224
- .orEmpty()
225
- .mapNotNull { read(it)?.name }
226
- .sorted()
227
-
228
220
  private fun clear() {
229
221
  directory.deleteRecursively()
230
222
  keyStore.aliases().toList()
@@ -321,9 +313,8 @@ private const val KEY_ID_SIZE = 16
321
313
  private const val WRAPPED_KEY_SIZE = KEY_SIZE / 8
322
314
  private const val IV_SIZE = 12
323
315
 
324
- /** A value's file: its name, key ID, wrapped data key, IV, then sealed value. */
316
+ /** A value's file: an unused string, then the key ID, wrapped data key, IV and sealed value. */
325
317
  private class StoredValue(
326
- val name: String,
327
318
  val keyId: ByteArray,
328
319
  val wrappedKey: ByteArray,
329
320
  val iv: ByteArray,
@@ -332,7 +323,7 @@ private class StoredValue(
332
323
  fun encode(): ByteArray {
333
324
  val bytes = ByteArrayOutputStream()
334
325
  DataOutputStream(bytes).run {
335
- writeUTF(name)
326
+ writeUTF("")
336
327
  write(keyId)
337
328
  write(wrappedKey)
338
329
  write(iv)
@@ -344,8 +335,8 @@ private class StoredValue(
344
335
  companion object {
345
336
  fun decode(bytes: ByteArray): StoredValue =
346
337
  DataInputStream(bytes.inputStream()).run {
338
+ readUTF()
347
339
  StoredValue(
348
- name = readUTF(),
349
340
  keyId = readExactly(KEY_ID_SIZE),
350
341
  wrappedKey = readExactly(WRAPPED_KEY_SIZE),
351
342
  iv = readExactly(IV_SIZE),
@@ -9,12 +9,7 @@ import Security
9
9
  final class TokamakSecureStoragePlugin: TokamakPlugin {
10
10
  let id = "secure-storage"
11
11
 
12
- /// Holds each value under a random ID.
13
- private let values = "tokamak.secure-storage"
14
- /// Holds each name's record: the ID of its value. Records have no access
15
- /// control, so `keys()` needs no authentication; on a device, reading even the
16
- /// attributes of an item saved with access control does.
17
- private let names = "tokamak.secure-storage.names"
12
+ private let service = "tokamak.secure-storage"
18
13
  /// Keychain calls block while the system authentication prompt is shown.
19
14
  private let queue = DispatchQueue(label: "tokamak.secure-storage")
20
15
  private let host: TokamakHost
@@ -92,21 +87,20 @@ final class TokamakSecureStoragePlugin: TokamakPlugin {
92
87
  prompting: prompting
93
88
  )
94
89
  case "delete":
95
- try delete(requiredString(arguments, "name"))
90
+ try deleteItems(query(requiredString(arguments, "name")))
96
91
  return nil
97
- case "keys":
98
- return try keys()
99
92
  case "clear":
100
- try deleteItems(serviceQuery(values))
101
- try deleteItems(serviceQuery(names))
93
+ try deleteItems(serviceQuery())
102
94
  return nil
103
95
  default:
104
96
  throw .notSupported("\(id).\(method) is not supported")
105
97
  }
106
98
  }
107
99
 
108
- /// Stores the value under a new ID before pointing the name's record at it, so
109
- /// a failed write leaves the previous value readable.
100
+ /// Replaces an existing value by deleting it and adding the new one, because
101
+ /// updating an item saved with access control needs authentication and
102
+ /// deleting it does not. Invalid options fail before the existing value is
103
+ /// deleted.
110
104
  private func set(
111
105
  _ name: String,
112
106
  value: String,
@@ -115,57 +109,19 @@ final class TokamakSecureStoragePlugin: TokamakPlugin {
115
109
  authentication: String?
116
110
  ) throws(TokamakPluginError) {
117
111
  let accessibility = try accessibility(readable, thisDeviceOnly: thisDeviceOnly)
118
- let id = UUID().uuidString
119
- var item = query(values, id)
112
+ var item = query(name)
120
113
  item[kSecValueData] = Data(value.utf8)
121
114
  if let authentication {
122
115
  item[kSecAttrAccessControl] = try accessControl(accessibility, authentication)
123
116
  } else {
124
117
  item[kSecAttrAccessible] = accessibility
125
118
  }
126
- let previous = try valueID(name)
127
- try check(SecItemAdd(item as CFDictionary, nil))
128
- do throws(TokamakPluginError) {
129
- try point(name, at: id, thisDeviceOnly: thisDeviceOnly)
130
- } catch {
131
- SecItemDelete(query(values, id) as CFDictionary)
132
- throw error
133
- }
134
- if let previous {
135
- try deleteItems(query(values, previous))
136
- }
137
- }
138
-
139
- /// Points `name`'s record at `id`. Records are readable after the first
140
- /// unlock, so `keys()` works while the device is locked.
141
- private func point(
142
- _ name: String,
143
- at id: String,
144
- thisDeviceOnly: Bool
145
- ) throws(TokamakPluginError) {
146
- let record: [CFString: Any] = [
147
- kSecValueData: Data(id.utf8),
148
- kSecAttrAccessible: thisDeviceOnly
149
- ? kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly : kSecAttrAccessibleAfterFirstUnlock,
150
- ]
151
- var status = SecItemUpdate(query(names, name) as CFDictionary, record as CFDictionary)
152
- if status == errSecItemNotFound {
153
- status = SecItemAdd(query(names, name).merging(record) { _, new in new } as CFDictionary, nil)
154
- }
155
- try check(status)
156
- }
157
-
158
- /// The ID of `name`'s value, or nil when `name` has no value.
159
- private func valueID(_ name: String) throws(TokamakPluginError) -> String? {
160
- var read = query(names, name)
161
- read[kSecReturnData] = true
162
- var data: CFTypeRef?
163
- let status = SecItemCopyMatching(read as CFDictionary, &data)
164
- if status == errSecItemNotFound {
165
- return nil
119
+ var status = SecItemAdd(item as CFDictionary, nil)
120
+ if status == errSecDuplicateItem {
121
+ try deleteItems(query(name))
122
+ status = SecItemAdd(item as CFDictionary, nil)
166
123
  }
167
124
  try check(status)
168
- return (data as? Data).map { String(decoding: $0, as: UTF8.self) }
169
125
  }
170
126
 
171
127
  /// Reads without UI first, so only a value saved with authentication needs
@@ -175,12 +131,14 @@ final class TokamakSecureStoragePlugin: TokamakPlugin {
175
131
  prompt: String?,
176
132
  prompting: Result<Void, TokamakPluginError>?
177
133
  ) throws(TokamakPluginError) -> String? {
178
- guard let id = try valueID(name) else { return nil }
179
- var item = query(values, id)
134
+ var item = query(name)
180
135
  item[kSecReturnData] = true
181
136
  item[kSecUseAuthenticationContext] = Self.contextWithoutUI()
182
137
  var data: CFTypeRef?
183
138
  var status = SecItemCopyMatching(item as CFDictionary, &data)
139
+ if status == errSecItemNotFound {
140
+ return nil
141
+ }
184
142
  if status == errSecInteractionNotAllowed, let prompting {
185
143
  try prompting.get()
186
144
  item[kSecUseAuthenticationContext] = prompt.flatMap { prompt -> LAContext? in
@@ -190,12 +148,13 @@ final class TokamakSecureStoragePlugin: TokamakPlugin {
190
148
  return context
191
149
  }
192
150
  status = SecItemCopyMatching(item as CFDictionary, &data)
193
- }
194
- if status == errSecItemNotFound {
195
- throw TokamakPluginError(
196
- name: "NotReadableError",
197
- message: "The stored value can no longer be read"
198
- )
151
+ // The first read found the value, so it exists but the system can no longer decrypt it.
152
+ if status == errSecItemNotFound {
153
+ throw TokamakPluginError(
154
+ name: "NotReadableError",
155
+ message: "The stored value can no longer be read"
156
+ )
157
+ }
199
158
  }
200
159
  try check(status)
201
160
  guard let data = data as? Data else {
@@ -204,29 +163,6 @@ final class TokamakSecureStoragePlugin: TokamakPlugin {
204
163
  return String(decoding: data, as: UTF8.self)
205
164
  }
206
165
 
207
- /// Deletes the value before its record, so a failure leaves the name listed.
208
- private func delete(_ name: String) throws(TokamakPluginError) {
209
- guard let id = try valueID(name) else { return }
210
- try deleteItems(query(values, id))
211
- try deleteItems(query(names, name))
212
- }
213
-
214
- private func keys() throws(TokamakPluginError) -> [String] {
215
- var records = serviceQuery(names)
216
- records[kSecReturnAttributes] = true
217
- records[kSecMatchLimit] = kSecMatchLimitAll
218
- var attributes: CFTypeRef?
219
- let status = SecItemCopyMatching(records as CFDictionary, &attributes)
220
- if status == errSecItemNotFound {
221
- return []
222
- }
223
- try check(status)
224
- let names = (attributes as? [[String: Any]] ?? []).compactMap {
225
- $0[kSecAttrAccount as String] as? String
226
- }
227
- return names.sorted()
228
- }
229
-
230
166
  private func deleteItems(_ query: [CFString: Any]) throws(TokamakPluginError) {
231
167
  let status = SecItemDelete(query as CFDictionary)
232
168
  if status != errSecItemNotFound {
@@ -242,9 +178,9 @@ final class TokamakSecureStoragePlugin: TokamakPlugin {
242
178
  return context
243
179
  }
244
180
 
245
- /// All of the plugin's items in `service`. The data protection keychain is
246
- /// the only keychain on iOS; macOS needs a team-signed build to use it.
247
- private func serviceQuery(_ service: String) -> [CFString: Any] {
181
+ /// All of the plugin's items. The data protection keychain is the only
182
+ /// keychain on iOS; macOS needs a team-signed build to use it.
183
+ private func serviceQuery() -> [CFString: Any] {
248
184
  [
249
185
  kSecClass: kSecClassGenericPassword,
250
186
  kSecAttrService: service,
@@ -252,8 +188,8 @@ final class TokamakSecureStoragePlugin: TokamakPlugin {
252
188
  ]
253
189
  }
254
190
 
255
- private func query(_ service: String, _ account: String) -> [CFString: Any] {
256
- serviceQuery(service).merging([kSecAttrAccount: account]) { _, account in account }
191
+ private func query(_ name: String) -> [CFString: Any] {
192
+ serviceQuery().merging([kSecAttrAccount: name]) { _, name in name }
257
193
  }
258
194
 
259
195
  /// `ThisDeviceOnly` items restore only to the device they were stored on.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokamakdev/plugin-secure-storage",
3
- "version": "0.1.0-beta.96",
3
+ "version": "0.1.0-beta.98",
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
  "peerDependencies": {
14
- "@tokamakdev/plugin": "0.1.0-beta.96"
14
+ "@tokamakdev/plugin": "0.1.0-beta.98"
15
15
  },
16
16
  "files": [
17
17
  "android",
package/src/index.ts CHANGED
@@ -45,11 +45,6 @@ class SecureStorage extends Plugin {
45
45
  return this.call("delete", { name });
46
46
  }
47
47
 
48
- /** The names of all stored values, sorted. */
49
- keys(): Promise<string[]> {
50
- return this.call("keys");
51
- }
52
-
53
48
  /** Deletes every stored value. */
54
49
  clear(): Promise<void> {
55
50
  return this.call("clear");