@tokamakdev/plugin-secure-storage 0.1.0-beta.101 → 0.1.0-beta.102

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
@@ -30,7 +30,7 @@ await secureStorage.replace("identity/encryption", base64Value, {
30
30
  // Replaces the value, or creates it when the name has none.
31
31
  await secureStorage.set("identity/encryption", base64Value, options);
32
32
 
33
- // string | null
33
+ // get, delete and clear reject with ItemNotFoundError when there is no value.
34
34
  const value = await secureStorage.get("identity/encryption", {
35
35
  prompt: "Confirm it's you to link a new device",
36
36
  });
@@ -75,7 +75,7 @@ screen. Android replaces values without asking.
75
75
  |---|---|
76
76
  | `NotSupportedError` | The platform, build or device cannot enforce the options |
77
77
  | `DuplicateItemError` | `create` was given a name that has a value |
78
- | `ItemNotFoundError` | `replace` was given a name that has no value |
78
+ | `ItemNotFoundError` | `replace`, `get` or `delete` was given a name that has no value, or `clear` found no values |
79
79
  | `MissingEntitlementError` | `keychainGroup` is not one of the app's keychain access groups |
80
80
  | `InvalidStateError` | The requested authentication is not set up on the device |
81
81
  | `NotAllowedError` | The user cancelled or failed authentication, or the device is locked |
@@ -84,8 +84,6 @@ screen. Android replaces values without asking.
84
84
  | `TypeError` | An argument is missing or has an unsupported value |
85
85
  | `OperationError` | The platform reported another failure |
86
86
 
87
- `get` resolves `null` when the name has no stored value.
88
-
89
87
  ## Keychain access groups
90
88
 
91
89
  On Apple platforms an app can store values in each keychain access group its
@@ -134,7 +134,7 @@ class TokamakSecureStoragePlugin(
134
134
  throw TokamakPluginError("DuplicateItemError", "A value is already stored under this name")
135
135
  }
136
136
  if (previous == null && mode == Write.REPLACE) {
137
- throw TokamakPluginError("ItemNotFoundError", "No value is stored under this name")
137
+ throw itemNotFound()
138
138
  }
139
139
  val previousAlias = previous?.let { alias(it.keyId) }
140
140
  val publicKey = generatePublicKey(spec)
@@ -188,11 +188,7 @@ class TokamakSecureStoragePlugin(
188
188
  private fun get(request: JSONObject, reply: TokamakPluginReply) {
189
189
  val name = request.requireString("name")
190
190
  val prompt = request.optionalString("prompt")
191
- val stored = read(file(name))
192
- if (stored == null) {
193
- reply(Result.success(null))
194
- return
195
- }
191
+ val stored = read(file(name)) ?: throw itemNotFound()
196
192
  val privateKey =
197
193
  keyStore.getKey(alias(stored.keyId), null) as? PrivateKey
198
194
  ?: throw notReadable()
@@ -237,16 +233,22 @@ class TokamakSecureStoragePlugin(
237
233
 
238
234
  private fun delete(name: String) {
239
235
  val file = file(name)
240
- val stored = read(file) ?: return
236
+ val stored = read(file) ?: throw itemNotFound()
241
237
  AtomicFile(file).delete()
242
238
  keyStore.deleteEntry(alias(stored.keyId))
243
239
  }
244
240
 
241
+ /** Deletes every value, and any key an interrupted write left behind. */
245
242
  private fun clear() {
243
+ // An interrupted first write leaves only a `.new` file, which [read] ignores.
244
+ val hadValues = directory.list().orEmpty().any { !it.endsWith(".new") }
246
245
  directory.deleteRecursively()
247
246
  keyStore.aliases().toList()
248
247
  .filter { it.startsWith(ALIAS_PREFIX) }
249
248
  .forEach(keyStore::deleteEntry)
249
+ if (!hadValues) {
250
+ throw TokamakPluginError("ItemNotFoundError", "No values are stored")
251
+ }
250
252
  }
251
253
 
252
254
  private fun read(file: File): StoredValue? =
@@ -324,6 +326,8 @@ class TokamakSecureStoragePlugin(
324
326
  fun notReadable() =
325
327
  TokamakPluginError("NotReadableError", "The stored value can no longer be decrypted")
326
328
 
329
+ fun itemNotFound() = TokamakPluginError("ItemNotFoundError", "No value is stored under this name")
330
+
327
331
  fun pluginError(error: Throwable): Throwable =
328
332
  when (error) {
329
333
  is KeyPermanentlyInvalidatedException,
@@ -103,10 +103,11 @@ final class TokamakSecureStoragePlugin: TokamakPlugin {
103
103
  prompting: prompting
104
104
  )
105
105
  case "delete":
106
- try deleteItems(query(requiredString(arguments, "name"), group: group))
106
+ let name = try requiredString(arguments, "name")
107
+ try check(SecItemDelete(query(name, group: group) as CFDictionary))
107
108
  return nil
108
109
  case "clear":
109
- try deleteItems(serviceQuery(group: group))
110
+ try check(SecItemDelete(serviceQuery(group: group) as CFDictionary))
110
111
  return nil
111
112
  default:
112
113
  throw .notSupported("\(id).\(method) is not supported")
@@ -158,15 +159,12 @@ final class TokamakSecureStoragePlugin: TokamakPlugin {
158
159
  group: String?,
159
160
  prompt: String?,
160
161
  prompting: Result<Void, TokamakPluginError>?
161
- ) throws(TokamakPluginError) -> String? {
162
+ ) throws(TokamakPluginError) -> String {
162
163
  var item = query(name, group: group)
163
164
  item[kSecReturnData] = true
164
165
  item[kSecUseAuthenticationContext] = Self.contextWithoutUI()
165
166
  var data: CFTypeRef?
166
167
  var status = SecItemCopyMatching(item as CFDictionary, &data)
167
- if status == errSecItemNotFound {
168
- return nil
169
- }
170
168
  if status == errSecInteractionNotAllowed, let prompting {
171
169
  try prompting.get()
172
170
  item[kSecUseAuthenticationContext] = Self.context(prompt: prompt)
@@ -186,13 +184,6 @@ final class TokamakSecureStoragePlugin: TokamakPlugin {
186
184
  return String(decoding: data, as: UTF8.self)
187
185
  }
188
186
 
189
- private func deleteItems(_ query: [CFString: Any]) throws(TokamakPluginError) {
190
- let status = SecItemDelete(query as CFDictionary)
191
- if status != errSecItemNotFound {
192
- try check(status)
193
- }
194
- }
195
-
196
187
  /// A context in which keychain calls that need authentication fail with
197
188
  /// `errSecInteractionNotAllowed` rather than show the system prompt.
198
189
  private static func contextWithoutUI() -> LAContext {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokamakdev/plugin-secure-storage",
3
- "version": "0.1.0-beta.101",
3
+ "version": "0.1.0-beta.102",
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.101"
14
+ "@tokamakdev/plugin": "0.1.0-beta.102"
15
15
  },
16
16
  "files": [
17
17
  "android",
package/src/index.ts CHANGED
@@ -64,15 +64,17 @@ class SecureStorage extends Plugin {
64
64
  return this.call("set", { ...stored(name, value, options), prompt: options.prompt ?? null });
65
65
  }
66
66
 
67
- get(name: string, options: GetOptions = {}): Promise<string | null> {
67
+ /** Reads the value; rejects with `ItemNotFoundError` when `name` has none. */
68
+ get(name: string, options: GetOptions = {}): Promise<string> {
68
69
  return this.call("get", { name, prompt: options.prompt ?? null, keychainGroup: options.keychainGroup ?? null });
69
70
  }
70
71
 
72
+ /** Deletes the value; rejects with `ItemNotFoundError` when `name` has none. */
71
73
  delete(name: string, options: DeleteOptions = {}): Promise<void> {
72
74
  return this.call("delete", { name, keychainGroup: options.keychainGroup ?? null });
73
75
  }
74
76
 
75
- /** Deletes every stored value. */
77
+ /** Deletes every stored value; rejects with `ItemNotFoundError` when there are none. */
76
78
  clear(options: DeleteOptions = {}): Promise<void> {
77
79
  return this.call("clear", { keychainGroup: options.keychainGroup ?? null });
78
80
  }