@tokamakdev/plugin-secure-storage 0.1.0-beta.83 → 0.1.0-beta.85

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
@@ -57,6 +57,7 @@ the platform's default prompt is shown.
57
57
  | `NotSupportedError` | The platform, build or device cannot enforce the options |
58
58
  | `InvalidStateError` | The requested authentication is not set up on the device |
59
59
  | `NotAllowedError` | The user cancelled or failed authentication, or the device is locked |
60
+ | `NeedsUIError` | Reading a value saved with `authentication` while the app is off screen on iOS or Android, where the system cannot show its prompt |
60
61
  | `NotReadableError` | A stored value can no longer be decrypted |
61
62
  | `TypeError` | An argument is missing or has an unsupported value |
62
63
  | `OperationError` | The platform reported another failure |
@@ -2,14 +2,21 @@ import Foundation
2
2
  import LocalAuthentication
3
3
  import Security
4
4
 
5
+ #if os(iOS)
6
+ import UIKit
7
+ #endif
8
+
5
9
  final class TokamakSecureStoragePlugin: TokamakPlugin {
6
10
  let id = "secure-storage"
7
11
 
8
12
  private let service = "tokamak.secure-storage"
9
13
  /// Keychain calls block while the system authentication prompt is shown.
10
14
  private let queue = DispatchQueue(label: "tokamak.secure-storage")
15
+ private let host: TokamakHost
11
16
 
12
- init(host: TokamakHost) {}
17
+ init(host: TokamakHost) {
18
+ self.host = host
19
+ }
13
20
 
14
21
  private static let teamSigningRequired = TokamakPluginError.notSupported(
15
22
  "Secure storage requires a team-signed build"
@@ -31,14 +38,33 @@ final class TokamakSecureStoragePlugin: TokamakPlugin {
31
38
  reply: @escaping TokamakPluginReply
32
39
  ) {
33
40
  let arguments = arguments as? [String: Any] ?? [:]
41
+ let prompting =
42
+ isUnlocked ? Result { () throws(TokamakPluginError) in try host.requireUI() } : nil
34
43
  queue.async {
35
- reply(Result { () throws(TokamakPluginError) in try self.perform(method, arguments) })
44
+ reply(
45
+ Result { () throws(TokamakPluginError) in
46
+ try self.perform(method, arguments, prompting: prompting)
47
+ })
36
48
  }
37
49
  }
38
50
 
51
+ /// Whether the device's protected data is available, which it is not while
52
+ /// an iPhone is locked. Read it on the main thread.
53
+ private var isUnlocked: Bool {
54
+ #if os(iOS)
55
+ UIApplication.shared.isProtectedDataAvailable
56
+ #else
57
+ true
58
+ #endif
59
+ }
60
+
61
+ /// `prompting` is whether a read that needs authentication may show the
62
+ /// system prompt, decided on the main thread as the call arrives: nil while
63
+ /// the device is locked, where such a read fails without UI anyway.
39
64
  private func perform(
40
65
  _ method: String,
41
- _ arguments: [String: Any]
66
+ _ arguments: [String: Any],
67
+ prompting: Result<Void, TokamakPluginError>?
42
68
  ) throws(TokamakPluginError) -> Any? {
43
69
  #if os(macOS)
44
70
  // Unentitled reads report errSecItemNotFound rather than a missing entitlement.
@@ -59,7 +85,8 @@ final class TokamakSecureStoragePlugin: TokamakPlugin {
59
85
  case "get":
60
86
  return try get(
61
87
  requiredString(arguments, "name"),
62
- prompt: optionalString(arguments, "prompt")
88
+ prompt: optionalString(arguments, "prompt"),
89
+ prompting: prompting
63
90
  )
64
91
  case "delete":
65
92
  try deleteItems(query(requiredString(arguments, "name")))
@@ -97,16 +124,28 @@ final class TokamakSecureStoragePlugin: TokamakPlugin {
97
124
  try check(status)
98
125
  }
99
126
 
100
- private func get(_ name: String, prompt: String?) throws(TokamakPluginError) -> String? {
127
+ /// Reads without UI first, so only a value saved with authentication needs
128
+ /// the app able to show the system prompt.
129
+ private func get(
130
+ _ name: String,
131
+ prompt: String?,
132
+ prompting: Result<Void, TokamakPluginError>?
133
+ ) throws(TokamakPluginError) -> String? {
101
134
  var item = query(name)
102
135
  item[kSecReturnData] = true
103
- if let prompt, !prompt.isEmpty {
104
- let context = LAContext()
105
- context.localizedReason = prompt
106
- item[kSecUseAuthenticationContext] = context
107
- }
136
+ item[kSecUseAuthenticationContext] = Self.contextWithoutUI()
108
137
  var data: CFTypeRef?
109
- let status = SecItemCopyMatching(item as CFDictionary, &data)
138
+ var status = SecItemCopyMatching(item as CFDictionary, &data)
139
+ if status == errSecInteractionNotAllowed, let prompting {
140
+ try prompting.get()
141
+ item[kSecUseAuthenticationContext] = prompt.flatMap { prompt -> LAContext? in
142
+ guard !prompt.isEmpty else { return nil }
143
+ let context = LAContext()
144
+ context.localizedReason = prompt
145
+ return context
146
+ }
147
+ status = SecItemCopyMatching(item as CFDictionary, &data)
148
+ }
110
149
  if status == errSecItemNotFound {
111
150
  guard try exists(name) else { return nil }
112
151
  throw TokamakPluginError(
@@ -154,13 +193,21 @@ final class TokamakSecureStoragePlugin: TokamakPlugin {
154
193
 
155
194
  /// `query` reading attributes only, which needs no authentication.
156
195
  private func attributesQuery(_ query: [CFString: Any]) -> [CFString: Any] {
157
- let context = LAContext()
158
- context.interactionNotAllowed = true
159
- return query.merging([kSecReturnAttributes: true, kSecUseAuthenticationContext: context]) {
196
+ query.merging([
197
+ kSecReturnAttributes: true, kSecUseAuthenticationContext: Self.contextWithoutUI(),
198
+ ]) {
160
199
  _, attribute in attribute
161
200
  }
162
201
  }
163
202
 
203
+ /// A context in which reads that need authentication fail with
204
+ /// `errSecInteractionNotAllowed` rather than show the system prompt.
205
+ private static func contextWithoutUI() -> LAContext {
206
+ let context = LAContext()
207
+ context.interactionNotAllowed = true
208
+ return context
209
+ }
210
+
164
211
  /// All of the plugin's items. The data protection keychain is the only
165
212
  /// keychain on iOS; macOS needs a team-signed build to use it.
166
213
  private func serviceQuery() -> [CFString: Any] {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokamakdev/plugin-secure-storage",
3
- "version": "0.1.0-beta.83",
3
+ "version": "0.1.0-beta.85",
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.83"
14
+ "@tokamakdev/plugin": "0.1.0-beta.85"
15
15
  },
16
16
  "files": [
17
17
  "android",
package/src/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { FrontendPlugin } from "@tokamakdev/plugin";
1
+ import { Plugin } from "@tokamakdev/plugin";
2
2
 
3
3
  /** When a stored value can be read. */
4
4
  export type Readable = "whenUnlocked" | "afterFirstUnlock";
@@ -22,7 +22,7 @@ export interface GetOptions {
22
22
  readonly prompt?: string;
23
23
  }
24
24
 
25
- class SecureStorage extends FrontendPlugin {
25
+ class SecureStorage extends Plugin {
26
26
  constructor() {
27
27
  super("secure-storage");
28
28
  }