@tokamakdev/plugin-secure-storage 0.1.0-beta.84 → 0.1.0-beta.86
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 +3 -0
- package/apple/SecureStoragePlugin.swift +90 -53
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -64,6 +64,9 @@ the platform's default prompt is shown.
|
|
|
64
64
|
|
|
65
65
|
`get` resolves `null` when the name has no stored value.
|
|
66
66
|
|
|
67
|
+
Names are not protected like values: `keys()` lists them whenever the device
|
|
68
|
+
has been unlocked since it started, without authentication.
|
|
69
|
+
|
|
67
70
|
## Platforms
|
|
68
71
|
|
|
69
72
|
- **iOS:** Keychain items, which survive deleting and reinstalling the app.
|
|
@@ -9,7 +9,12 @@ import Security
|
|
|
9
9
|
final class TokamakSecureStoragePlugin: TokamakPlugin {
|
|
10
10
|
let id = "secure-storage"
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
/// Holds each value under a random ID.
|
|
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"
|
|
13
18
|
/// Keychain calls block while the system authentication prompt is shown.
|
|
14
19
|
private let queue = DispatchQueue(label: "tokamak.secure-storage")
|
|
15
20
|
private let host: TokamakHost
|
|
@@ -38,18 +43,19 @@ final class TokamakSecureStoragePlugin: TokamakPlugin {
|
|
|
38
43
|
reply: @escaping TokamakPluginReply
|
|
39
44
|
) {
|
|
40
45
|
let arguments = arguments as? [String: Any] ?? [:]
|
|
41
|
-
let
|
|
46
|
+
let prompting =
|
|
47
|
+
isUnlocked ? Result { () throws(TokamakPluginError) in try host.requireUI() } : nil
|
|
42
48
|
queue.async {
|
|
43
49
|
reply(
|
|
44
50
|
Result { () throws(TokamakPluginError) in
|
|
45
|
-
try self.perform(method, arguments,
|
|
51
|
+
try self.perform(method, arguments, prompting: prompting)
|
|
46
52
|
})
|
|
47
53
|
}
|
|
48
54
|
}
|
|
49
55
|
|
|
50
56
|
/// Whether the device's protected data is available, which it is not while
|
|
51
57
|
/// an iPhone is locked. Read it on the main thread.
|
|
52
|
-
private
|
|
58
|
+
private var isUnlocked: Bool {
|
|
53
59
|
#if os(iOS)
|
|
54
60
|
UIApplication.shared.isProtectedDataAvailable
|
|
55
61
|
#else
|
|
@@ -57,10 +63,13 @@ final class TokamakSecureStoragePlugin: TokamakPlugin {
|
|
|
57
63
|
#endif
|
|
58
64
|
}
|
|
59
65
|
|
|
66
|
+
/// `prompting` is whether a read that needs authentication may show the
|
|
67
|
+
/// system prompt, decided on the main thread as the call arrives: nil while
|
|
68
|
+
/// the device is locked, where such a read fails without UI anyway.
|
|
60
69
|
private func perform(
|
|
61
70
|
_ method: String,
|
|
62
71
|
_ arguments: [String: Any],
|
|
63
|
-
|
|
72
|
+
prompting: Result<Void, TokamakPluginError>?
|
|
64
73
|
) throws(TokamakPluginError) -> Any? {
|
|
65
74
|
#if os(macOS)
|
|
66
75
|
// Unentitled reads report errSecItemNotFound rather than a missing entitlement.
|
|
@@ -71,10 +80,8 @@ final class TokamakSecureStoragePlugin: TokamakPlugin {
|
|
|
71
80
|
try set(
|
|
72
81
|
requiredString(arguments, "name"),
|
|
73
82
|
value: requiredString(arguments, "value"),
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
thisDeviceOnly: requiredBool(arguments, "thisDeviceOnly")
|
|
77
|
-
),
|
|
83
|
+
readable: requiredString(arguments, "readable"),
|
|
84
|
+
thisDeviceOnly: requiredBool(arguments, "thisDeviceOnly"),
|
|
78
85
|
authentication: optionalString(arguments, "authentication")
|
|
79
86
|
)
|
|
80
87
|
return nil
|
|
@@ -82,59 +89,100 @@ final class TokamakSecureStoragePlugin: TokamakPlugin {
|
|
|
82
89
|
return try get(
|
|
83
90
|
requiredString(arguments, "name"),
|
|
84
91
|
prompt: optionalString(arguments, "prompt"),
|
|
85
|
-
|
|
92
|
+
prompting: prompting
|
|
86
93
|
)
|
|
87
94
|
case "delete":
|
|
88
|
-
try
|
|
95
|
+
try delete(requiredString(arguments, "name"))
|
|
89
96
|
return nil
|
|
90
97
|
case "keys":
|
|
91
98
|
return try keys()
|
|
92
99
|
case "clear":
|
|
93
|
-
try deleteItems(serviceQuery())
|
|
100
|
+
try deleteItems(serviceQuery(values))
|
|
101
|
+
try deleteItems(serviceQuery(names))
|
|
94
102
|
return nil
|
|
95
103
|
default:
|
|
96
104
|
throw .notSupported("\(id).\(method) is not supported")
|
|
97
105
|
}
|
|
98
106
|
}
|
|
99
107
|
|
|
100
|
-
///
|
|
101
|
-
/// failed write
|
|
108
|
+
/// Stores the value under a new ID before pointing the name's record at it, so
|
|
109
|
+
/// a failed write leaves the previous value readable.
|
|
102
110
|
private func set(
|
|
103
111
|
_ name: String,
|
|
104
112
|
value: String,
|
|
105
|
-
|
|
113
|
+
readable: String,
|
|
114
|
+
thisDeviceOnly: Bool,
|
|
106
115
|
authentication: String?
|
|
107
116
|
) throws(TokamakPluginError) {
|
|
108
|
-
|
|
117
|
+
let accessibility = try accessibility(readable, thisDeviceOnly: thisDeviceOnly)
|
|
118
|
+
let id = UUID().uuidString
|
|
119
|
+
var item = query(values, id)
|
|
109
120
|
item[kSecValueData] = Data(value.utf8)
|
|
110
121
|
if let authentication {
|
|
111
122
|
item[kSecAttrAccessControl] = try accessControl(accessibility, authentication)
|
|
112
123
|
} else {
|
|
113
124
|
item[kSecAttrAccessible] = accessibility
|
|
114
125
|
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
126
|
+
let previous = try valueID(name)
|
|
127
|
+
try check(SecItemAdd(item as CFDictionary, nil))
|
|
128
|
+
do throws(TokamakPluginError) {
|
|
129
|
+
try point(name, at: id, thisDeviceOnly: thisDeviceOnly)
|
|
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
166
|
}
|
|
120
167
|
try check(status)
|
|
168
|
+
return (data as? Data).map { String(decoding: $0, as: UTF8.self) }
|
|
121
169
|
}
|
|
122
170
|
|
|
123
171
|
/// Reads without UI first, so only a value saved with authentication needs
|
|
124
|
-
/// the app able to show the system prompt.
|
|
125
|
-
/// read fails without UI whatever the value needs.
|
|
172
|
+
/// the app able to show the system prompt.
|
|
126
173
|
private func get(
|
|
127
174
|
_ name: String,
|
|
128
175
|
prompt: String?,
|
|
129
|
-
|
|
176
|
+
prompting: Result<Void, TokamakPluginError>?
|
|
130
177
|
) throws(TokamakPluginError) -> String? {
|
|
131
|
-
|
|
178
|
+
guard let id = try valueID(name) else { return nil }
|
|
179
|
+
var item = query(values, id)
|
|
132
180
|
item[kSecReturnData] = true
|
|
133
181
|
item[kSecUseAuthenticationContext] = Self.contextWithoutUI()
|
|
134
182
|
var data: CFTypeRef?
|
|
135
183
|
var status = SecItemCopyMatching(item as CFDictionary, &data)
|
|
136
|
-
if status == errSecInteractionNotAllowed
|
|
137
|
-
try
|
|
184
|
+
if status == errSecInteractionNotAllowed, let prompting {
|
|
185
|
+
try prompting.get()
|
|
138
186
|
item[kSecUseAuthenticationContext] = prompt.flatMap { prompt -> LAContext? in
|
|
139
187
|
guard !prompt.isEmpty else { return nil }
|
|
140
188
|
let context = LAContext()
|
|
@@ -144,7 +192,6 @@ final class TokamakSecureStoragePlugin: TokamakPlugin {
|
|
|
144
192
|
status = SecItemCopyMatching(item as CFDictionary, &data)
|
|
145
193
|
}
|
|
146
194
|
if status == errSecItemNotFound {
|
|
147
|
-
guard try exists(name) else { return nil }
|
|
148
195
|
throw TokamakPluginError(
|
|
149
196
|
name: "NotReadableError",
|
|
150
197
|
message: "The stored value can no longer be read"
|
|
@@ -157,11 +204,19 @@ final class TokamakSecureStoragePlugin: TokamakPlugin {
|
|
|
157
204
|
return String(decoding: data, as: UTF8.self)
|
|
158
205
|
}
|
|
159
206
|
|
|
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
|
+
|
|
160
214
|
private func keys() throws(TokamakPluginError) -> [String] {
|
|
161
|
-
var
|
|
162
|
-
|
|
215
|
+
var records = serviceQuery(names)
|
|
216
|
+
records[kSecReturnAttributes] = true
|
|
217
|
+
records[kSecMatchLimit] = kSecMatchLimitAll
|
|
163
218
|
var attributes: CFTypeRef?
|
|
164
|
-
let status = SecItemCopyMatching(
|
|
219
|
+
let status = SecItemCopyMatching(records as CFDictionary, &attributes)
|
|
165
220
|
if status == errSecItemNotFound {
|
|
166
221
|
return []
|
|
167
222
|
}
|
|
@@ -179,24 +234,6 @@ final class TokamakSecureStoragePlugin: TokamakPlugin {
|
|
|
179
234
|
}
|
|
180
235
|
}
|
|
181
236
|
|
|
182
|
-
private func exists(_ name: String) throws(TokamakPluginError) -> Bool {
|
|
183
|
-
let status = SecItemCopyMatching(attributesQuery(query(name)) as CFDictionary, nil)
|
|
184
|
-
if status == errSecItemNotFound {
|
|
185
|
-
return false
|
|
186
|
-
}
|
|
187
|
-
try check(status)
|
|
188
|
-
return true
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
/// `query` reading attributes only, which needs no authentication.
|
|
192
|
-
private func attributesQuery(_ query: [CFString: Any]) -> [CFString: Any] {
|
|
193
|
-
query.merging([
|
|
194
|
-
kSecReturnAttributes: true, kSecUseAuthenticationContext: Self.contextWithoutUI(),
|
|
195
|
-
]) {
|
|
196
|
-
_, attribute in attribute
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
|
|
200
237
|
/// A context in which reads that need authentication fail with
|
|
201
238
|
/// `errSecInteractionNotAllowed` rather than show the system prompt.
|
|
202
239
|
private static func contextWithoutUI() -> LAContext {
|
|
@@ -205,9 +242,9 @@ final class TokamakSecureStoragePlugin: TokamakPlugin {
|
|
|
205
242
|
return context
|
|
206
243
|
}
|
|
207
244
|
|
|
208
|
-
/// All of the plugin's items
|
|
209
|
-
/// keychain on iOS; macOS needs a team-signed build to use it.
|
|
210
|
-
private func serviceQuery() -> [CFString: Any] {
|
|
245
|
+
/// All of the plugin's items in `service`. The data protection keychain is
|
|
246
|
+
/// the only keychain on iOS; macOS needs a team-signed build to use it.
|
|
247
|
+
private func serviceQuery(_ service: String) -> [CFString: Any] {
|
|
211
248
|
[
|
|
212
249
|
kSecClass: kSecClassGenericPassword,
|
|
213
250
|
kSecAttrService: service,
|
|
@@ -215,8 +252,8 @@ final class TokamakSecureStoragePlugin: TokamakPlugin {
|
|
|
215
252
|
]
|
|
216
253
|
}
|
|
217
254
|
|
|
218
|
-
private func query(_
|
|
219
|
-
serviceQuery().merging([kSecAttrAccount:
|
|
255
|
+
private func query(_ service: String, _ account: String) -> [CFString: Any] {
|
|
256
|
+
serviceQuery(service).merging([kSecAttrAccount: account]) { _, account in account }
|
|
220
257
|
}
|
|
221
258
|
|
|
222
259
|
/// `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.86",
|
|
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.86"
|
|
15
15
|
},
|
|
16
16
|
"files": [
|
|
17
17
|
"android",
|