@tokamakdev/plugin-secure-storage 0.1.0-beta.85 → 0.1.0-beta.88
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 +77 -43
- 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
|
|
@@ -75,10 +80,8 @@ final class TokamakSecureStoragePlugin: TokamakPlugin {
|
|
|
75
80
|
try set(
|
|
76
81
|
requiredString(arguments, "name"),
|
|
77
82
|
value: requiredString(arguments, "value"),
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
thisDeviceOnly: requiredBool(arguments, "thisDeviceOnly")
|
|
81
|
-
),
|
|
83
|
+
readable: requiredString(arguments, "readable"),
|
|
84
|
+
thisDeviceOnly: requiredBool(arguments, "thisDeviceOnly"),
|
|
82
85
|
authentication: optionalString(arguments, "authentication")
|
|
83
86
|
)
|
|
84
87
|
return nil
|
|
@@ -89,39 +92,80 @@ final class TokamakSecureStoragePlugin: TokamakPlugin {
|
|
|
89
92
|
prompting: prompting
|
|
90
93
|
)
|
|
91
94
|
case "delete":
|
|
92
|
-
try
|
|
95
|
+
try delete(requiredString(arguments, "name"))
|
|
93
96
|
return nil
|
|
94
97
|
case "keys":
|
|
95
98
|
return try keys()
|
|
96
99
|
case "clear":
|
|
97
|
-
try deleteItems(serviceQuery())
|
|
100
|
+
try deleteItems(serviceQuery(values))
|
|
101
|
+
try deleteItems(serviceQuery(names))
|
|
98
102
|
return nil
|
|
99
103
|
default:
|
|
100
104
|
throw .notSupported("\(id).\(method) is not supported")
|
|
101
105
|
}
|
|
102
106
|
}
|
|
103
107
|
|
|
104
|
-
///
|
|
105
|
-
/// 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.
|
|
106
110
|
private func set(
|
|
107
111
|
_ name: String,
|
|
108
112
|
value: String,
|
|
109
|
-
|
|
113
|
+
readable: String,
|
|
114
|
+
thisDeviceOnly: Bool,
|
|
110
115
|
authentication: String?
|
|
111
116
|
) throws(TokamakPluginError) {
|
|
112
|
-
|
|
117
|
+
let accessibility = try accessibility(readable, thisDeviceOnly: thisDeviceOnly)
|
|
118
|
+
let id = UUID().uuidString
|
|
119
|
+
var item = query(values, id)
|
|
113
120
|
item[kSecValueData] = Data(value.utf8)
|
|
114
121
|
if let authentication {
|
|
115
122
|
item[kSecAttrAccessControl] = try accessControl(accessibility, authentication)
|
|
116
123
|
} else {
|
|
117
124
|
item[kSecAttrAccessible] = accessibility
|
|
118
125
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
|
123
166
|
}
|
|
124
167
|
try check(status)
|
|
168
|
+
return (data as? Data).map { String(decoding: $0, as: UTF8.self) }
|
|
125
169
|
}
|
|
126
170
|
|
|
127
171
|
/// Reads without UI first, so only a value saved with authentication needs
|
|
@@ -131,7 +175,8 @@ final class TokamakSecureStoragePlugin: TokamakPlugin {
|
|
|
131
175
|
prompt: String?,
|
|
132
176
|
prompting: Result<Void, TokamakPluginError>?
|
|
133
177
|
) throws(TokamakPluginError) -> String? {
|
|
134
|
-
|
|
178
|
+
guard let id = try valueID(name) else { return nil }
|
|
179
|
+
var item = query(values, id)
|
|
135
180
|
item[kSecReturnData] = true
|
|
136
181
|
item[kSecUseAuthenticationContext] = Self.contextWithoutUI()
|
|
137
182
|
var data: CFTypeRef?
|
|
@@ -147,7 +192,6 @@ final class TokamakSecureStoragePlugin: TokamakPlugin {
|
|
|
147
192
|
status = SecItemCopyMatching(item as CFDictionary, &data)
|
|
148
193
|
}
|
|
149
194
|
if status == errSecItemNotFound {
|
|
150
|
-
guard try exists(name) else { return nil }
|
|
151
195
|
throw TokamakPluginError(
|
|
152
196
|
name: "NotReadableError",
|
|
153
197
|
message: "The stored value can no longer be read"
|
|
@@ -160,11 +204,19 @@ final class TokamakSecureStoragePlugin: TokamakPlugin {
|
|
|
160
204
|
return String(decoding: data, as: UTF8.self)
|
|
161
205
|
}
|
|
162
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
|
+
|
|
163
214
|
private func keys() throws(TokamakPluginError) -> [String] {
|
|
164
|
-
var
|
|
165
|
-
|
|
215
|
+
var records = serviceQuery(names)
|
|
216
|
+
records[kSecReturnAttributes] = true
|
|
217
|
+
records[kSecMatchLimit] = kSecMatchLimitAll
|
|
166
218
|
var attributes: CFTypeRef?
|
|
167
|
-
let status = SecItemCopyMatching(
|
|
219
|
+
let status = SecItemCopyMatching(records as CFDictionary, &attributes)
|
|
168
220
|
if status == errSecItemNotFound {
|
|
169
221
|
return []
|
|
170
222
|
}
|
|
@@ -182,24 +234,6 @@ final class TokamakSecureStoragePlugin: TokamakPlugin {
|
|
|
182
234
|
}
|
|
183
235
|
}
|
|
184
236
|
|
|
185
|
-
private func exists(_ name: String) throws(TokamakPluginError) -> Bool {
|
|
186
|
-
let status = SecItemCopyMatching(attributesQuery(query(name)) as CFDictionary, nil)
|
|
187
|
-
if status == errSecItemNotFound {
|
|
188
|
-
return false
|
|
189
|
-
}
|
|
190
|
-
try check(status)
|
|
191
|
-
return true
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
/// `query` reading attributes only, which needs no authentication.
|
|
195
|
-
private func attributesQuery(_ query: [CFString: Any]) -> [CFString: Any] {
|
|
196
|
-
query.merging([
|
|
197
|
-
kSecReturnAttributes: true, kSecUseAuthenticationContext: Self.contextWithoutUI(),
|
|
198
|
-
]) {
|
|
199
|
-
_, attribute in attribute
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
|
|
203
237
|
/// A context in which reads that need authentication fail with
|
|
204
238
|
/// `errSecInteractionNotAllowed` rather than show the system prompt.
|
|
205
239
|
private static func contextWithoutUI() -> LAContext {
|
|
@@ -208,9 +242,9 @@ final class TokamakSecureStoragePlugin: TokamakPlugin {
|
|
|
208
242
|
return context
|
|
209
243
|
}
|
|
210
244
|
|
|
211
|
-
/// All of the plugin's items
|
|
212
|
-
/// keychain on iOS; macOS needs a team-signed build to use it.
|
|
213
|
-
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] {
|
|
214
248
|
[
|
|
215
249
|
kSecClass: kSecClassGenericPassword,
|
|
216
250
|
kSecAttrService: service,
|
|
@@ -218,8 +252,8 @@ final class TokamakSecureStoragePlugin: TokamakPlugin {
|
|
|
218
252
|
]
|
|
219
253
|
}
|
|
220
254
|
|
|
221
|
-
private func query(_
|
|
222
|
-
serviceQuery().merging([kSecAttrAccount:
|
|
255
|
+
private func query(_ service: String, _ account: String) -> [CFString: Any] {
|
|
256
|
+
serviceQuery(service).merging([kSecAttrAccount: account]) { _, account in account }
|
|
223
257
|
}
|
|
224
258
|
|
|
225
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.88",
|
|
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.88"
|
|
15
15
|
},
|
|
16
16
|
"files": [
|
|
17
17
|
"android",
|