@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 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
- private let service = "tokamak.secure-storage"
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
- accessibility: accessibility(
79
- requiredString(arguments, "readable"),
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 deleteItems(query(requiredString(arguments, "name")))
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
- /// Replaces an existing value only once the new item can be added, so a
105
- /// failed write keeps the old value.
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
- accessibility: CFString,
113
+ readable: String,
114
+ thisDeviceOnly: Bool,
110
115
  authentication: String?
111
116
  ) throws(TokamakPluginError) {
112
- var item = query(name)
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
- var status = SecItemAdd(item as CFDictionary, nil)
120
- if status == errSecDuplicateItem {
121
- try deleteItems(query(name))
122
- status = SecItemAdd(item as CFDictionary, nil)
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
- var item = query(name)
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 items = attributesQuery(serviceQuery())
165
- items[kSecMatchLimit] = kSecMatchLimitAll
215
+ var records = serviceQuery(names)
216
+ records[kSecReturnAttributes] = true
217
+ records[kSecMatchLimit] = kSecMatchLimitAll
166
218
  var attributes: CFTypeRef?
167
- let status = SecItemCopyMatching(items as CFDictionary, &attributes)
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. The data protection keychain is the only
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(_ name: String) -> [CFString: Any] {
222
- serviceQuery().merging([kSecAttrAccount: name]) { _, name in name }
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.85",
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.85"
14
+ "@tokamakdev/plugin": "0.1.0-beta.88"
15
15
  },
16
16
  "files": [
17
17
  "android",