@tokamakdev/plugin-secure-storage 0.1.0-beta.100 → 0.1.0-beta.101
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 +40 -4
- package/android/SecureStoragePlugin.kt +33 -5
- package/apple/SecureStoragePlugin.swift +78 -51
- package/package.json +2 -2
- package/src/index.ts +46 -14
package/README.md
CHANGED
|
@@ -11,13 +11,25 @@ npm install @tokamakdev/plugin-secure-storage
|
|
|
11
11
|
```ts
|
|
12
12
|
import { secureStorage } from "@tokamakdev/plugin-secure-storage";
|
|
13
13
|
|
|
14
|
-
|
|
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
|
|
|
30
|
+
// Replaces the value, or creates it when the name has none.
|
|
31
|
+
await secureStorage.set("identity/encryption", base64Value, options);
|
|
32
|
+
|
|
21
33
|
// string | null
|
|
22
34
|
const value = await secureStorage.get("identity/encryption", {
|
|
23
35
|
prompt: "Confirm it's you to link a new device",
|
|
@@ -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`
|
|
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
|
|
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` was given a name that has no value |
|
|
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 |
|
|
@@ -71,6 +86,25 @@ replaces values without asking.
|
|
|
71
86
|
|
|
72
87
|
`get` resolves `null` when the name has no stored value.
|
|
73
88
|
|
|
89
|
+
## Keychain access groups
|
|
90
|
+
|
|
91
|
+
On Apple platforms an app can store values in each keychain access group its
|
|
92
|
+
`keychain-access-groups` entitlement lists. `keychainGroup` selects one, as
|
|
93
|
+
`kSecAttrAccessGroup` does in a native keychain query:
|
|
94
|
+
|
|
95
|
+
- `create`, and `set` when the name has no value, store the value in
|
|
96
|
+
`keychainGroup`, or in the first listed group when it is omitted.
|
|
97
|
+
- `get`, `replace`, `delete` and `clear`, and `set` when the name has a value,
|
|
98
|
+
act on `keychainGroup`, or on every group when it is omitted.
|
|
99
|
+
|
|
100
|
+
A name can have a value in more than one group. Without `keychainGroup`, `get`
|
|
101
|
+
resolves one of them, `delete` and `clear` delete all of them, and `replace`
|
|
102
|
+
and `set` reject with `NotAllowedError`, which is how the keychain reports an
|
|
103
|
+
update that matches more than one value (`errSecAuthFailed`).
|
|
104
|
+
|
|
105
|
+
Other platforms have no equivalent and reject `keychainGroup` with
|
|
106
|
+
`NotSupportedError`.
|
|
107
|
+
|
|
74
108
|
## Platforms
|
|
75
109
|
|
|
76
110
|
- **iOS:** Keychain items, which survive deleting and reinstalling the app.
|
|
@@ -103,6 +137,8 @@ groups, the app's own first, in the app's entitlements file
|
|
|
103
137
|
|
|
104
138
|
New values go into the app's own group; existing values stay in the shared
|
|
105
139
|
group even when replaced, so keep `TEAMID.*` while users may still hold them.
|
|
140
|
+
Pass `keychainGroup: "TEAMID.*"` to act on those values only, for example to
|
|
141
|
+
delete one after creating it again in the app's own group.
|
|
106
142
|
|
|
107
143
|
Removing the device passcode or screen lock makes values stored with
|
|
108
144
|
`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
|
-
|
|
75
|
+
store(request(arguments), Write.SET)
|
|
68
76
|
reply(Result.success(null))
|
|
69
77
|
}
|
|
70
|
-
"get" -> execute(reply) { get(
|
|
78
|
+
"get" -> execute(reply) { get(request(arguments), reply) }
|
|
71
79
|
"delete" -> execute(reply) {
|
|
72
|
-
delete(
|
|
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
|
|
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
|
|
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 TokamakPluginError("ItemNotFoundError", "No value is stored under this name")
|
|
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())
|
|
@@ -251,6 +276,9 @@ class TokamakSecureStoragePlugin(
|
|
|
251
276
|
|
|
252
277
|
private fun ByteArray.toHex() = joinToString("") { "%02x".format(it) }
|
|
253
278
|
|
|
279
|
+
/** Whether a write requires the name to have no value, a value, or either. */
|
|
280
|
+
private enum class Write { CREATE, REPLACE, SET }
|
|
281
|
+
|
|
254
282
|
private enum class Authentication(
|
|
255
283
|
val wireName: String,
|
|
256
284
|
val keyTypes: Int,
|
|
@@ -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,79 +64,102 @@ 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 {
|
|
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
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
+
try deleteItems(query(requiredString(arguments, "name"), group: group))
|
|
93
107
|
return nil
|
|
94
108
|
case "clear":
|
|
95
|
-
try deleteItems(serviceQuery())
|
|
109
|
+
try deleteItems(serviceQuery(group: group))
|
|
96
110
|
return nil
|
|
97
111
|
default:
|
|
98
112
|
throw .notSupported("\(id).\(method) is not supported")
|
|
99
113
|
}
|
|
100
114
|
}
|
|
101
115
|
|
|
102
|
-
///
|
|
103
|
-
///
|
|
104
|
-
|
|
105
|
-
|
|
116
|
+
/// The value and its protection, as keychain attributes. Access control even
|
|
117
|
+
/// without authentication, so an update removes any the existing value had.
|
|
118
|
+
private func stored(_ arguments: [String: Any]) throws(TokamakPluginError) -> [CFString: Any] {
|
|
119
|
+
let accessibility = try accessibility(
|
|
120
|
+
requiredString(arguments, "readable"),
|
|
121
|
+
thisDeviceOnly: requiredBool(arguments, "thisDeviceOnly")
|
|
122
|
+
)
|
|
123
|
+
return [
|
|
124
|
+
kSecValueData: Data(try requiredString(arguments, "value").utf8),
|
|
125
|
+
kSecAttrAccessControl: try accessControl(
|
|
126
|
+
accessibility, optionalString(arguments, "authentication")),
|
|
127
|
+
]
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/// Adds `name`'s value in one keychain call: errSecDuplicateItem when it has one.
|
|
131
|
+
private func add(_ name: String, group: String?, _ stored: [CFString: Any]) -> OSStatus {
|
|
132
|
+
SecItemAdd(query(name, group: group).merging(stored) { _, new in new } as CFDictionary, nil)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/// Updates `name`'s value in place, so a failure leaves the previous value:
|
|
136
|
+
/// errSecItemNotFound when it has none. Tries without UI
|
|
137
|
+
/// first, so only a value saved with authentication needs the system prompt.
|
|
138
|
+
private func update(
|
|
106
139
|
_ name: String,
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
thisDeviceOnly: Bool,
|
|
110
|
-
authentication: String?,
|
|
140
|
+
group: String?,
|
|
141
|
+
_ stored: [CFString: Any],
|
|
111
142
|
prompt: String?,
|
|
112
143
|
prompting: Result<Void, TokamakPluginError>?
|
|
113
|
-
) throws(TokamakPluginError) {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
let
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
]
|
|
120
|
-
|
|
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))
|
|
144
|
+
) throws(TokamakPluginError) -> OSStatus {
|
|
145
|
+
var item = query(name, group: group)
|
|
146
|
+
item[kSecUseAuthenticationContext] = Self.contextWithoutUI()
|
|
147
|
+
let status = SecItemUpdate(item as CFDictionary, stored as CFDictionary)
|
|
148
|
+
guard status == errSecInteractionNotAllowed, let prompting else { return status }
|
|
149
|
+
try prompting.get()
|
|
150
|
+
item[kSecUseAuthenticationContext] = Self.context(prompt: prompt)
|
|
151
|
+
return SecItemUpdate(item as CFDictionary, stored as CFDictionary)
|
|
134
152
|
}
|
|
135
153
|
|
|
136
154
|
/// Reads without UI first, so only a value saved with authentication needs
|
|
137
155
|
/// the app able to show the system prompt.
|
|
138
156
|
private func get(
|
|
139
157
|
_ name: String,
|
|
158
|
+
group: String?,
|
|
140
159
|
prompt: String?,
|
|
141
160
|
prompting: Result<Void, TokamakPluginError>?
|
|
142
161
|
) throws(TokamakPluginError) -> String? {
|
|
143
|
-
var item = query(name)
|
|
162
|
+
var item = query(name, group: group)
|
|
144
163
|
item[kSecReturnData] = true
|
|
145
164
|
item[kSecUseAuthenticationContext] = Self.contextWithoutUI()
|
|
146
165
|
var data: CFTypeRef?
|
|
@@ -174,7 +193,7 @@ final class TokamakSecureStoragePlugin: TokamakPlugin {
|
|
|
174
193
|
}
|
|
175
194
|
}
|
|
176
195
|
|
|
177
|
-
/// A context in which
|
|
196
|
+
/// A context in which keychain calls that need authentication fail with
|
|
178
197
|
/// `errSecInteractionNotAllowed` rather than show the system prompt.
|
|
179
198
|
private static func contextWithoutUI() -> LAContext {
|
|
180
199
|
let context = LAContext()
|
|
@@ -190,18 +209,22 @@ final class TokamakSecureStoragePlugin: TokamakPlugin {
|
|
|
190
209
|
return context
|
|
191
210
|
}
|
|
192
211
|
|
|
193
|
-
///
|
|
194
|
-
///
|
|
195
|
-
|
|
196
|
-
|
|
212
|
+
/// The plugin's items in keychain access group `group`, or in every group
|
|
213
|
+
/// of the app when nil, where an add uses the app's first group. The data
|
|
214
|
+
/// protection keychain is the only keychain on iOS; macOS needs a
|
|
215
|
+
/// team-signed build to use it.
|
|
216
|
+
private func serviceQuery(group: String?) -> [CFString: Any] {
|
|
217
|
+
var query: [CFString: Any] = [
|
|
197
218
|
kSecClass: kSecClassGenericPassword,
|
|
198
219
|
kSecAttrService: service,
|
|
199
220
|
kSecUseDataProtectionKeychain: true,
|
|
200
221
|
]
|
|
222
|
+
query[kSecAttrAccessGroup] = group
|
|
223
|
+
return query
|
|
201
224
|
}
|
|
202
225
|
|
|
203
|
-
private func query(_ name: String) -> [CFString: Any] {
|
|
204
|
-
serviceQuery().merging([kSecAttrAccount: name]) { _, name in name }
|
|
226
|
+
private func query(_ name: String, group: String?) -> [CFString: Any] {
|
|
227
|
+
serviceQuery(group: group).merging([kSecAttrAccount: name]) { _, name in name }
|
|
205
228
|
}
|
|
206
229
|
|
|
207
230
|
/// `ThisDeviceOnly` items restore only to the device they were stored on.
|
|
@@ -255,8 +278,12 @@ final class TokamakSecureStoragePlugin: TokamakPlugin {
|
|
|
255
278
|
guard status != errSecSuccess else { return }
|
|
256
279
|
let message = SecCopyErrorMessageString(status, nil) as String? ?? "Keychain error \(status)"
|
|
257
280
|
switch status {
|
|
281
|
+
case errSecDuplicateItem:
|
|
282
|
+
throw TokamakPluginError(name: "DuplicateItemError", message: message)
|
|
283
|
+
case errSecItemNotFound:
|
|
284
|
+
throw TokamakPluginError(name: "ItemNotFoundError", message: message)
|
|
258
285
|
case errSecMissingEntitlement:
|
|
259
|
-
throw
|
|
286
|
+
throw TokamakPluginError(name: "MissingEntitlementError", message: message)
|
|
260
287
|
case errSecUserCanceled, errSecAuthFailed, errSecInteractionNotAllowed:
|
|
261
288
|
throw TokamakPluginError(name: "NotAllowedError", message: message)
|
|
262
289
|
default:
|
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.101",
|
|
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.
|
|
14
|
+
"@tokamakdev/plugin": "0.1.0-beta.101"
|
|
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
|
|
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,44 @@ 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
67
|
get(name: string, options: GetOptions = {}): Promise<string | null> {
|
|
47
|
-
return this.call("get", { name, prompt: options.prompt ?? null });
|
|
68
|
+
return this.call("get", { name, prompt: options.prompt ?? null, keychainGroup: options.keychainGroup ?? null });
|
|
48
69
|
}
|
|
49
70
|
|
|
50
|
-
delete(name: string): Promise<void> {
|
|
51
|
-
return this.call("delete", { name });
|
|
71
|
+
delete(name: string, options: DeleteOptions = {}): Promise<void> {
|
|
72
|
+
return this.call("delete", { name, keychainGroup: options.keychainGroup ?? null });
|
|
52
73
|
}
|
|
53
74
|
|
|
54
75
|
/** Deletes every stored value. */
|
|
55
|
-
clear(): Promise<void> {
|
|
56
|
-
return this.call("clear");
|
|
76
|
+
clear(options: DeleteOptions = {}): Promise<void> {
|
|
77
|
+
return this.call("clear", { keychainGroup: options.keychainGroup ?? null });
|
|
57
78
|
}
|
|
58
79
|
}
|
|
59
80
|
|
|
81
|
+
function stored(name: string, value: string, options: CreateOptions) {
|
|
82
|
+
return {
|
|
83
|
+
name,
|
|
84
|
+
value,
|
|
85
|
+
readable: options.readable,
|
|
86
|
+
authentication: options.authentication ?? null,
|
|
87
|
+
thisDeviceOnly: options.thisDeviceOnly ?? true,
|
|
88
|
+
keychainGroup: options.keychainGroup ?? null,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
60
92
|
export const secureStorage = new SecureStorage();
|