@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 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
@@ -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 unlocked = Self.isUnlocked
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, unlocked: unlocked)
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 static var isUnlocked: Bool {
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
- unlocked: Bool
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
- accessibility: accessibility(
75
- requiredString(arguments, "readable"),
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
- unlocked: unlocked
92
+ prompting: prompting
86
93
  )
87
94
  case "delete":
88
- try deleteItems(query(requiredString(arguments, "name")))
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
- /// Replaces an existing value only once the new item can be added, so a
101
- /// 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.
102
110
  private func set(
103
111
  _ name: String,
104
112
  value: String,
105
- accessibility: CFString,
113
+ readable: String,
114
+ thisDeviceOnly: Bool,
106
115
  authentication: String?
107
116
  ) throws(TokamakPluginError) {
108
- var item = query(name)
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
- var status = SecItemAdd(item as CFDictionary, nil)
116
- if status == errSecDuplicateItem {
117
- try deleteItems(query(name))
118
- 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
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. While the device is locked, the
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
- unlocked: Bool
176
+ prompting: Result<Void, TokamakPluginError>?
130
177
  ) throws(TokamakPluginError) -> String? {
131
- var item = query(name)
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 && unlocked {
137
- try host.requireUI()
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 items = attributesQuery(serviceQuery())
162
- items[kSecMatchLimit] = kSecMatchLimitAll
215
+ var records = serviceQuery(names)
216
+ records[kSecReturnAttributes] = true
217
+ records[kSecMatchLimit] = kSecMatchLimitAll
163
218
  var attributes: CFTypeRef?
164
- let status = SecItemCopyMatching(items as CFDictionary, &attributes)
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. The data protection keychain is the only
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(_ name: String) -> [CFString: Any] {
219
- 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 }
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.84",
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.84"
14
+ "@tokamakdev/plugin": "0.1.0-beta.86"
15
15
  },
16
16
  "files": [
17
17
  "android",