@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 +40 -6
- package/android/SecureStoragePlugin.kt +43 -11
- package/apple/SecureStoragePlugin.swift +80 -62
- package/package.json +2 -2
- package/src/index.ts +50 -16
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
|
-
|
|
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
|
-
//
|
|
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`
|
|
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`, `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
|
-
|
|
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
|
-
|
|
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 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) ?:
|
|
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 {
|
|
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
|
|
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
|
|
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
|
-
///
|
|
103
|
-
///
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
108
|
-
|
|
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
|
-
|
|
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))
|
|
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
|
-
|
|
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
|
-
///
|
|
194
|
-
///
|
|
195
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
47
|
-
|
|
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
|
-
|
|
51
|
-
|
|
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();
|