@tokamakdev/plugin-secure-storage 0.1.0-beta.82 → 0.1.0-beta.84

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,29 @@ final class TokamakSecureStoragePlugin: TokamakPlugin {
31
38
  reply: @escaping TokamakPluginReply
32
39
  ) {
33
40
  let arguments = arguments as? [String: Any] ?? [:]
41
+ let unlocked = Self.isUnlocked
34
42
  queue.async {
35
- reply(Result { () throws(TokamakPluginError) in try self.perform(method, arguments) })
43
+ reply(
44
+ Result { () throws(TokamakPluginError) in
45
+ try self.perform(method, arguments, unlocked: unlocked)
46
+ })
36
47
  }
37
48
  }
38
49
 
50
+ /// Whether the device's protected data is available, which it is not while
51
+ /// an iPhone is locked. Read it on the main thread.
52
+ private static var isUnlocked: Bool {
53
+ #if os(iOS)
54
+ UIApplication.shared.isProtectedDataAvailable
55
+ #else
56
+ true
57
+ #endif
58
+ }
59
+
39
60
  private func perform(
40
61
  _ method: String,
41
- _ arguments: [String: Any]
62
+ _ arguments: [String: Any],
63
+ unlocked: Bool
42
64
  ) throws(TokamakPluginError) -> Any? {
43
65
  #if os(macOS)
44
66
  // Unentitled reads report errSecItemNotFound rather than a missing entitlement.
@@ -59,7 +81,8 @@ final class TokamakSecureStoragePlugin: TokamakPlugin {
59
81
  case "get":
60
82
  return try get(
61
83
  requiredString(arguments, "name"),
62
- prompt: optionalString(arguments, "prompt")
84
+ prompt: optionalString(arguments, "prompt"),
85
+ unlocked: unlocked
63
86
  )
64
87
  case "delete":
65
88
  try deleteItems(query(requiredString(arguments, "name")))
@@ -97,16 +120,29 @@ final class TokamakSecureStoragePlugin: TokamakPlugin {
97
120
  try check(status)
98
121
  }
99
122
 
100
- private func get(_ name: String, prompt: String?) throws(TokamakPluginError) -> String? {
123
+ /// 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.
126
+ private func get(
127
+ _ name: String,
128
+ prompt: String?,
129
+ unlocked: Bool
130
+ ) throws(TokamakPluginError) -> String? {
101
131
  var item = query(name)
102
132
  item[kSecReturnData] = true
103
- if let prompt, !prompt.isEmpty {
104
- let context = LAContext()
105
- context.localizedReason = prompt
106
- item[kSecUseAuthenticationContext] = context
107
- }
133
+ item[kSecUseAuthenticationContext] = Self.contextWithoutUI()
108
134
  var data: CFTypeRef?
109
- let status = SecItemCopyMatching(item as CFDictionary, &data)
135
+ var status = SecItemCopyMatching(item as CFDictionary, &data)
136
+ if status == errSecInteractionNotAllowed && unlocked {
137
+ try host.requireUI()
138
+ item[kSecUseAuthenticationContext] = prompt.flatMap { prompt -> LAContext? in
139
+ guard !prompt.isEmpty else { return nil }
140
+ let context = LAContext()
141
+ context.localizedReason = prompt
142
+ return context
143
+ }
144
+ status = SecItemCopyMatching(item as CFDictionary, &data)
145
+ }
110
146
  if status == errSecItemNotFound {
111
147
  guard try exists(name) else { return nil }
112
148
  throw TokamakPluginError(
@@ -154,13 +190,21 @@ final class TokamakSecureStoragePlugin: TokamakPlugin {
154
190
 
155
191
  /// `query` reading attributes only, which needs no authentication.
156
192
  private func attributesQuery(_ query: [CFString: Any]) -> [CFString: Any] {
157
- let context = LAContext()
158
- context.interactionNotAllowed = true
159
- return query.merging([kSecReturnAttributes: true, kSecUseAuthenticationContext: context]) {
193
+ query.merging([
194
+ kSecReturnAttributes: true, kSecUseAuthenticationContext: Self.contextWithoutUI(),
195
+ ]) {
160
196
  _, attribute in attribute
161
197
  }
162
198
  }
163
199
 
200
+ /// A context in which reads that need authentication fail with
201
+ /// `errSecInteractionNotAllowed` rather than show the system prompt.
202
+ private static func contextWithoutUI() -> LAContext {
203
+ let context = LAContext()
204
+ context.interactionNotAllowed = true
205
+ return context
206
+ }
207
+
164
208
  /// All of the plugin's items. The data protection keychain is the only
165
209
  /// keychain on iOS; macOS needs a team-signed build to use it.
166
210
  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.82",
3
+ "version": "0.1.0-beta.84",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/mantty/tokamak.git",
@@ -10,8 +10,8 @@
10
10
  "exports": {
11
11
  ".": "./src/index.ts"
12
12
  },
13
- "dependencies": {
14
- "@tokamakdev/plugin": "0.1.0-beta.82"
13
+ "peerDependencies": {
14
+ "@tokamakdev/plugin": "0.1.0-beta.84"
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
  }