@tokamakdev/plugin-secure-storage 0.1.0-beta.100 → 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
@@ -11,14 +11,26 @@ npm install @tokamakdev/plugin-secure-storage
11
11
  ```ts
12
12
  import { secureStorage } from "@tokamakdev/plugin-secure-storage";
13
13
 
14
- await secureStorage.set("identity/encryption", base64Value, {
14
+ const options = {
15
15
  // Required: "whenUnlocked" or "afterFirstUnlock".
16
16
  readable: "whenUnlocked",
17
17
  // Reads need no authentication when omitted.
18
18
  authentication: "biometricsOrPasscode",
19
+ } as const;
20
+
21
+ // Rejects with DuplicateItemError when the name has a value.
22
+ await secureStorage.create("identity/encryption", base64Value, options);
23
+
24
+ // Rejects with ItemNotFoundError when the name has no value.
25
+ await secureStorage.replace("identity/encryption", base64Value, {
26
+ ...options,
27
+ prompt: "Confirm it's you to replace your key",
19
28
  });
20
29
 
21
- // string | null
30
+ // Replaces the value, or creates it when the name has none.
31
+ await secureStorage.set("identity/encryption", base64Value, options);
32
+
33
+ // get, delete and clear reject with ItemNotFoundError when there is no value.
22
34
  const value = await secureStorage.get("identity/encryption", {
23
35
  prompt: "Confirm it's you to link a new device",
24
36
  });
@@ -51,17 +63,20 @@ enrolment change as failed authentication, so `get` rejects with
51
63
  `prompt` is the reason shown in the system authentication prompt. Without it,
52
64
  the platform's default prompt is shown.
53
65
 
54
- `set` replaces an existing value in place or adds a new one, so a failed `set`
66
+ `replace` and `set` replace an existing value in place, so a failed write
55
67
  leaves the previous value. On Apple platforms the system asks for the
56
68
  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.
69
+ for a read: `replace` and `set` then show their `prompt` and need the app on
70
+ screen. Android replaces values without asking.
59
71
 
60
72
  ## Errors
61
73
 
62
74
  | Name | When |
63
75
  |---|---|
64
76
  | `NotSupportedError` | The platform, build or device cannot enforce the options |
77
+ | `DuplicateItemError` | `create` was given a name that has a value |
78
+ | `ItemNotFoundError` | `replace`, `get` or `delete` was given a name that has no value, or `clear` found no values |
79
+ | `MissingEntitlementError` | `keychainGroup` is not one of the app's keychain access groups |
65
80
  | `InvalidStateError` | The requested authentication is not set up on the device |
66
81
  | `NotAllowedError` | The user cancelled or failed authentication, or the device is locked |
67
82
  | `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 |
@@ -69,7 +84,24 @@ replaces values without asking.
69
84
  | `TypeError` | An argument is missing or has an unsupported value |
70
85
  | `OperationError` | The platform reported another failure |
71
86
 
72
- `get` resolves `null` when the name has no stored value.
87
+ ## Keychain access groups
88
+
89
+ On Apple platforms an app can store values in each keychain access group its
90
+ `keychain-access-groups` entitlement lists. `keychainGroup` selects one, as
91
+ `kSecAttrAccessGroup` does in a native keychain query:
92
+
93
+ - `create`, and `set` when the name has no value, store the value in
94
+ `keychainGroup`, or in the first listed group when it is omitted.
95
+ - `get`, `replace`, `delete` and `clear`, and `set` when the name has a value,
96
+ act on `keychainGroup`, or on every group when it is omitted.
97
+
98
+ A name can have a value in more than one group. Without `keychainGroup`, `get`
99
+ resolves one of them, `delete` and `clear` delete all of them, and `replace`
100
+ and `set` reject with `NotAllowedError`, which is how the keychain reports an
101
+ update that matches more than one value (`errSecAuthFailed`).
102
+
103
+ Other platforms have no equivalent and reject `keychainGroup` with
104
+ `NotSupportedError`.
73
105
 
74
106
  ## Platforms
75
107
 
@@ -103,6 +135,8 @@ groups, the app's own first, in the app's entitlements file
103
135
 
104
136
  New values go into the app's own group; existing values stay in the shared
105
137
  group even when replaced, so keep `TEAMID.*` while users may still hold them.
138
+ Pass `keychainGroup: "TEAMID.*"` to act on those values only, for example to
139
+ delete one after creating it again in the app's own group.
106
140
 
107
141
  Removing the device passcode or screen lock makes values stored with
108
142
  `authentication` permanently unreadable on Android.
@@ -63,16 +63,25 @@ class TokamakSecureStoragePlugin(
63
63
 
64
64
  override fun call(method: String, arguments: Any?, reply: TokamakPluginReply) {
65
65
  when (method) {
66
+ "create" -> execute(reply) {
67
+ store(request(arguments), Write.CREATE)
68
+ reply(Result.success(null))
69
+ }
70
+ "replace" -> execute(reply) {
71
+ store(request(arguments), Write.REPLACE)
72
+ reply(Result.success(null))
73
+ }
66
74
  "set" -> execute(reply) {
67
- set(requireObject(arguments))
75
+ store(request(arguments), Write.SET)
68
76
  reply(Result.success(null))
69
77
  }
70
- "get" -> execute(reply) { get(requireObject(arguments), reply) }
78
+ "get" -> execute(reply) { get(request(arguments), reply) }
71
79
  "delete" -> execute(reply) {
72
- delete(requireObject(arguments).requireString("name"))
80
+ delete(request(arguments).requireString("name"))
73
81
  reply(Result.success(null))
74
82
  }
75
83
  "clear" -> execute(reply) {
84
+ request(arguments) // Rejects keychainGroup.
76
85
  clear()
77
86
  reply(Result.success(null))
78
87
  }
@@ -80,6 +89,15 @@ class TokamakSecureStoragePlugin(
80
89
  }
81
90
  }
82
91
 
92
+ /** The call's arguments, rejecting the Apple-only `keychainGroup`. */
93
+ private fun request(arguments: Any?): JSONObject {
94
+ val request = requireObject(arguments)
95
+ if (request.optionalString("keychainGroup") != null) {
96
+ throw TokamakPluginError.notSupported("keychainGroup is an Apple keychain feature")
97
+ }
98
+ return request
99
+ }
100
+
83
101
  private fun execute(reply: TokamakPluginReply, operation: () -> Unit) =
84
102
  worker.execute {
85
103
  runCatching(operation).onFailure { reply(Result.failure(pluginError(it))) }
@@ -90,7 +108,7 @@ class TokamakSecureStoragePlugin(
90
108
  runCatching(operation).onFailure { reply(Result.failure(pluginError(it))) }
91
109
  }
92
110
 
93
- private fun set(request: JSONObject) {
111
+ private fun store(request: JSONObject, mode: Write) {
94
112
  val name = request.requireString("name")
95
113
  val value = request.requireString("value")
96
114
  val unlockedDeviceRequired =
@@ -111,7 +129,14 @@ class TokamakSecureStoragePlugin(
111
129
  .setUnlockedDeviceRequired(unlockedDeviceRequired)
112
130
  request.optionalString("authentication")?.let { requireAuthentication(spec, it) }
113
131
  val file = file(name)
114
- val previousAlias = read(file)?.let { alias(it.keyId) }
132
+ val previous = read(file)
133
+ if (previous != null && mode == Write.CREATE) {
134
+ throw TokamakPluginError("DuplicateItemError", "A value is already stored under this name")
135
+ }
136
+ if (previous == null && mode == Write.REPLACE) {
137
+ throw itemNotFound()
138
+ }
139
+ val previousAlias = previous?.let { alias(it.keyId) }
115
140
  val publicKey = generatePublicKey(spec)
116
141
  try {
117
142
  write(file, seal(keyId, value, publicKey).encode())
@@ -163,11 +188,7 @@ class TokamakSecureStoragePlugin(
163
188
  private fun get(request: JSONObject, reply: TokamakPluginReply) {
164
189
  val name = request.requireString("name")
165
190
  val prompt = request.optionalString("prompt")
166
- val stored = read(file(name))
167
- if (stored == null) {
168
- reply(Result.success(null))
169
- return
170
- }
191
+ val stored = read(file(name)) ?: throw itemNotFound()
171
192
  val privateKey =
172
193
  keyStore.getKey(alias(stored.keyId), null) as? PrivateKey
173
194
  ?: throw notReadable()
@@ -212,16 +233,22 @@ class TokamakSecureStoragePlugin(
212
233
 
213
234
  private fun delete(name: String) {
214
235
  val file = file(name)
215
- val stored = read(file) ?: return
236
+ val stored = read(file) ?: throw itemNotFound()
216
237
  AtomicFile(file).delete()
217
238
  keyStore.deleteEntry(alias(stored.keyId))
218
239
  }
219
240
 
241
+ /** Deletes every value, and any key an interrupted write left behind. */
220
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") }
221
245
  directory.deleteRecursively()
222
246
  keyStore.aliases().toList()
223
247
  .filter { it.startsWith(ALIAS_PREFIX) }
224
248
  .forEach(keyStore::deleteEntry)
249
+ if (!hadValues) {
250
+ throw TokamakPluginError("ItemNotFoundError", "No values are stored")
251
+ }
225
252
  }
226
253
 
227
254
  private fun read(file: File): StoredValue? =
@@ -251,6 +278,9 @@ class TokamakSecureStoragePlugin(
251
278
 
252
279
  private fun ByteArray.toHex() = joinToString("") { "%02x".format(it) }
253
280
 
281
+ /** Whether a write requires the name to have no value, a value, or either. */
282
+ private enum class Write { CREATE, REPLACE, SET }
283
+
254
284
  private enum class Authentication(
255
285
  val wireName: String,
256
286
  val keyTypes: Int,
@@ -296,6 +326,8 @@ class TokamakSecureStoragePlugin(
296
326
  fun notReadable() =
297
327
  TokamakPluginError("NotReadableError", "The stored value can no longer be decrypted")
298
328
 
329
+ fun itemNotFound() = TokamakPluginError("ItemNotFoundError", "No value is stored under this name")
330
+
299
331
  fun pluginError(error: Throwable): Throwable =
300
332
  when (error) {
301
333
  is KeyPermanentlyInvalidatedException,
@@ -18,10 +18,6 @@ final class TokamakSecureStoragePlugin: TokamakPlugin {
18
18
  self.host = host
19
19
  }
20
20
 
21
- private static let teamSigningRequired = TokamakPluginError.notSupported(
22
- "Secure storage requires a team-signed build"
23
- )
24
-
25
21
  #if os(macOS)
26
22
  /// macOS grants the data protection keychain only to apps whose provisioning
27
23
  /// profile provides an application identifier.
@@ -68,86 +64,107 @@ final class TokamakSecureStoragePlugin: TokamakPlugin {
68
64
  ) throws(TokamakPluginError) -> Any? {
69
65
  #if os(macOS)
70
66
  // Unentitled reads report errSecItemNotFound rather than a missing entitlement.
71
- guard Self.hasApplicationIdentifier else { throw Self.teamSigningRequired }
67
+ guard Self.hasApplicationIdentifier else {
68
+ throw .notSupported("Secure storage requires a team-signed build")
69
+ }
72
70
  #endif
71
+ let group = try optionalString(arguments, "keychainGroup")
73
72
  switch method {
73
+ case "create":
74
+ try check(add(requiredString(arguments, "name"), group: group, stored(arguments)))
75
+ return nil
76
+ case "replace":
77
+ try check(
78
+ update(
79
+ requiredString(arguments, "name"),
80
+ group: group,
81
+ stored(arguments),
82
+ prompt: optionalString(arguments, "prompt"),
83
+ prompting: prompting
84
+ ))
85
+ return nil
74
86
  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"),
87
+ let name = try requiredString(arguments, "name")
88
+ let stored = try stored(arguments)
89
+ let status = try update(
90
+ name,
91
+ group: group,
92
+ stored,
81
93
  prompt: optionalString(arguments, "prompt"),
82
94
  prompting: prompting
83
95
  )
96
+ try check(status == errSecItemNotFound ? add(name, group: group, stored) : status)
84
97
  return nil
85
98
  case "get":
86
99
  return try get(
87
100
  requiredString(arguments, "name"),
101
+ group: group,
88
102
  prompt: optionalString(arguments, "prompt"),
89
103
  prompting: prompting
90
104
  )
91
105
  case "delete":
92
- try deleteItems(query(requiredString(arguments, "name")))
106
+ let name = try requiredString(arguments, "name")
107
+ try check(SecItemDelete(query(name, group: group) as CFDictionary))
93
108
  return nil
94
109
  case "clear":
95
- try deleteItems(serviceQuery())
110
+ try check(SecItemDelete(serviceQuery(group: group) as CFDictionary))
96
111
  return nil
97
112
  default:
98
113
  throw .notSupported("\(id).\(method) is not supported")
99
114
  }
100
115
  }
101
116
 
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(
117
+ /// The value and its protection, as keychain attributes. Access control even
118
+ /// without authentication, so an update removes any the existing value had.
119
+ private func stored(_ arguments: [String: Any]) throws(TokamakPluginError) -> [CFString: Any] {
120
+ let accessibility = try accessibility(
121
+ requiredString(arguments, "readable"),
122
+ thisDeviceOnly: requiredBool(arguments, "thisDeviceOnly")
123
+ )
124
+ return [
125
+ kSecValueData: Data(try requiredString(arguments, "value").utf8),
126
+ kSecAttrAccessControl: try accessControl(
127
+ accessibility, optionalString(arguments, "authentication")),
128
+ ]
129
+ }
130
+
131
+ /// Adds `name`'s value in one keychain call: errSecDuplicateItem when it has one.
132
+ private func add(_ name: String, group: String?, _ stored: [CFString: Any]) -> OSStatus {
133
+ SecItemAdd(query(name, group: group).merging(stored) { _, new in new } as CFDictionary, nil)
134
+ }
135
+
136
+ /// Updates `name`'s value in place, so a failure leaves the previous value:
137
+ /// errSecItemNotFound when it has none. Tries without UI
138
+ /// first, so only a value saved with authentication needs the system prompt.
139
+ private func update(
106
140
  _ name: String,
107
- value: String,
108
- readable: String,
109
- thisDeviceOnly: Bool,
110
- authentication: String?,
141
+ group: String?,
142
+ _ stored: [CFString: Any],
111
143
  prompt: String?,
112
144
  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))
145
+ ) throws(TokamakPluginError) -> OSStatus {
146
+ var item = query(name, group: group)
147
+ item[kSecUseAuthenticationContext] = Self.contextWithoutUI()
148
+ let status = SecItemUpdate(item as CFDictionary, stored as CFDictionary)
149
+ guard status == errSecInteractionNotAllowed, let prompting else { return status }
150
+ try prompting.get()
151
+ item[kSecUseAuthenticationContext] = Self.context(prompt: prompt)
152
+ return SecItemUpdate(item as CFDictionary, stored as CFDictionary)
134
153
  }
135
154
 
136
155
  /// Reads without UI first, so only a value saved with authentication needs
137
156
  /// the app able to show the system prompt.
138
157
  private func get(
139
158
  _ name: String,
159
+ group: String?,
140
160
  prompt: String?,
141
161
  prompting: Result<Void, TokamakPluginError>?
142
- ) throws(TokamakPluginError) -> String? {
143
- var item = query(name)
162
+ ) throws(TokamakPluginError) -> String {
163
+ var item = query(name, group: group)
144
164
  item[kSecReturnData] = true
145
165
  item[kSecUseAuthenticationContext] = Self.contextWithoutUI()
146
166
  var data: CFTypeRef?
147
167
  var status = SecItemCopyMatching(item as CFDictionary, &data)
148
- if status == errSecItemNotFound {
149
- return nil
150
- }
151
168
  if status == errSecInteractionNotAllowed, let prompting {
152
169
  try prompting.get()
153
170
  item[kSecUseAuthenticationContext] = Self.context(prompt: prompt)
@@ -167,14 +184,7 @@ final class TokamakSecureStoragePlugin: TokamakPlugin {
167
184
  return String(decoding: data, as: UTF8.self)
168
185
  }
169
186
 
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
187
+ /// A context in which keychain calls that need authentication fail with
178
188
  /// `errSecInteractionNotAllowed` rather than show the system prompt.
179
189
  private static func contextWithoutUI() -> LAContext {
180
190
  let context = LAContext()
@@ -190,18 +200,22 @@ final class TokamakSecureStoragePlugin: TokamakPlugin {
190
200
  return context
191
201
  }
192
202
 
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
- [
203
+ /// The plugin's items in keychain access group `group`, or in every group
204
+ /// of the app when nil, where an add uses the app's first group. The data
205
+ /// protection keychain is the only keychain on iOS; macOS needs a
206
+ /// team-signed build to use it.
207
+ private func serviceQuery(group: String?) -> [CFString: Any] {
208
+ var query: [CFString: Any] = [
197
209
  kSecClass: kSecClassGenericPassword,
198
210
  kSecAttrService: service,
199
211
  kSecUseDataProtectionKeychain: true,
200
212
  ]
213
+ query[kSecAttrAccessGroup] = group
214
+ return query
201
215
  }
202
216
 
203
- private func query(_ name: String) -> [CFString: Any] {
204
- serviceQuery().merging([kSecAttrAccount: name]) { _, name in name }
217
+ private func query(_ name: String, group: String?) -> [CFString: Any] {
218
+ serviceQuery(group: group).merging([kSecAttrAccount: name]) { _, name in name }
205
219
  }
206
220
 
207
221
  /// `ThisDeviceOnly` items restore only to the device they were stored on.
@@ -255,8 +269,12 @@ final class TokamakSecureStoragePlugin: TokamakPlugin {
255
269
  guard status != errSecSuccess else { return }
256
270
  let message = SecCopyErrorMessageString(status, nil) as String? ?? "Keychain error \(status)"
257
271
  switch status {
272
+ case errSecDuplicateItem:
273
+ throw TokamakPluginError(name: "DuplicateItemError", message: message)
274
+ case errSecItemNotFound:
275
+ throw TokamakPluginError(name: "ItemNotFoundError", message: message)
258
276
  case errSecMissingEntitlement:
259
- throw Self.teamSigningRequired
277
+ throw TokamakPluginError(name: "MissingEntitlementError", message: message)
260
278
  case errSecUserCanceled, errSecAuthFailed, errSecInteractionNotAllowed:
261
279
  throw TokamakPluginError(name: "NotAllowedError", message: message)
262
280
  default:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokamakdev/plugin-secure-storage",
3
- "version": "0.1.0-beta.100",
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.100"
14
+ "@tokamakdev/plugin": "0.1.0-beta.102"
15
15
  },
16
16
  "files": [
17
17
  "android",
package/src/index.ts CHANGED
@@ -6,7 +6,7 @@ export type Readable = "whenUnlocked" | "afterFirstUnlock";
6
6
  /** The authentication a read requires. */
7
7
  export type Authentication = "biometricsOrPasscode" | "biometrics" | "currentBiometrics";
8
8
 
9
- export interface SetOptions {
9
+ export interface CreateOptions {
10
10
  readonly readable: Readable;
11
11
  /** Reads need no authentication when omitted. */
12
12
  readonly authentication?: Authentication;
@@ -15,6 +15,15 @@ export interface SetOptions {
15
15
  * false lets a backup restore it to a new device where the platform supports that.
16
16
  */
17
17
  readonly thisDeviceOnly?: boolean;
18
+ /**
19
+ * Apple platforms: the keychain access group, one of the app's `keychain-access-groups`.
20
+ * When omitted, new values go into the first of them and updates match every group.
21
+ */
22
+ readonly keychainGroup?: string;
23
+ }
24
+
25
+ /** Options for `set` and `replace`. */
26
+ export interface SetOptions extends CreateOptions {
18
27
  /**
19
28
  * The reason shown in the system authentication prompt. Apple platforms ask for the
20
29
  * authentication an existing value was saved with before replacing it.
@@ -25,6 +34,14 @@ export interface SetOptions {
25
34
  export interface GetOptions {
26
35
  /** The reason shown in the system authentication prompt. */
27
36
  readonly prompt?: string;
37
+ /** Apple platforms: the keychain access group to read from. Defaults to all of the app's. */
38
+ readonly keychainGroup?: string;
39
+ }
40
+
41
+ /** Options for `delete` and `clear`. */
42
+ export interface DeleteOptions {
43
+ /** Apple platforms: the keychain access group to delete from. Defaults to all of the app's. */
44
+ readonly keychainGroup?: string;
28
45
  }
29
46
 
30
47
  class SecureStorage extends Plugin {
@@ -32,29 +49,46 @@ class SecureStorage extends Plugin {
32
49
  super("secure-storage");
33
50
  }
34
51
 
52
+ /** Stores a new value; rejects with `DuplicateItemError` when `name` has one. */
53
+ create(name: string, value: string, options: CreateOptions): Promise<void> {
54
+ return this.call("create", stored(name, value, options));
55
+ }
56
+
57
+ /** Replaces the value in place; rejects with `ItemNotFoundError` when `name` has none. */
58
+ replace(name: string, value: string, options: SetOptions): Promise<void> {
59
+ return this.call("replace", { ...stored(name, value, options), prompt: options.prompt ?? null });
60
+ }
61
+
62
+ /** Replaces the value in place, or stores a new one when `name` has none. */
35
63
  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
- });
64
+ return this.call("set", { ...stored(name, value, options), prompt: options.prompt ?? null });
44
65
  }
45
66
 
46
- get(name: string, options: GetOptions = {}): Promise<string | null> {
47
- return this.call("get", { name, prompt: options.prompt ?? null });
67
+ /** Reads the value; rejects with `ItemNotFoundError` when `name` has none. */
68
+ get(name: string, options: GetOptions = {}): Promise<string> {
69
+ return this.call("get", { name, prompt: options.prompt ?? null, keychainGroup: options.keychainGroup ?? null });
48
70
  }
49
71
 
50
- delete(name: string): Promise<void> {
51
- return this.call("delete", { name });
72
+ /** Deletes the value; rejects with `ItemNotFoundError` when `name` has none. */
73
+ delete(name: string, options: DeleteOptions = {}): Promise<void> {
74
+ return this.call("delete", { name, keychainGroup: options.keychainGroup ?? null });
52
75
  }
53
76
 
54
- /** Deletes every stored value. */
55
- clear(): Promise<void> {
56
- return this.call("clear");
77
+ /** Deletes every stored value; rejects with `ItemNotFoundError` when there are none. */
78
+ clear(options: DeleteOptions = {}): Promise<void> {
79
+ return this.call("clear", { keychainGroup: options.keychainGroup ?? null });
57
80
  }
58
81
  }
59
82
 
83
+ function stored(name: string, value: string, options: CreateOptions) {
84
+ return {
85
+ name,
86
+ value,
87
+ readable: options.readable,
88
+ authentication: options.authentication ?? null,
89
+ thisDeviceOnly: options.thisDeviceOnly ?? true,
90
+ keychainGroup: options.keychainGroup ?? null,
91
+ };
92
+ }
93
+
60
94
  export const secureStorage = new SecureStorage();