@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 +4 -6
- package/android/SecureStoragePlugin.kt +6 -15
- package/apple/SecureStoragePlugin.swift +28 -92
- package/package.json +2 -2
- package/src/index.ts +0 -5
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(
|
|
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(
|
|
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(
|
|
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:
|
|
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(
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
///
|
|
109
|
-
///
|
|
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
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
|
246
|
-
///
|
|
247
|
-
private func serviceQuery(
|
|
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(_
|
|
256
|
-
serviceQuery(
|
|
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.
|
|
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.
|
|
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");
|