@tokamakdev/plugin-secure-storage 0.1.0-beta.100 → 0.1.0-beta.101

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
@@ -11,13 +11,25 @@ npm install @tokamakdev/plugin-secure-storage
11
11
  ```ts
12
12
  import { secureStorage } from "@tokamakdev/plugin-secure-storage";
13
13
 
14
- await secureStorage.set("identity/encryption", base64Value, {
14
+ const options = {
15
15
  // Required: "whenUnlocked" or "afterFirstUnlock".
16
16
  readable: "whenUnlocked",
17
17
  // Reads need no authentication when omitted.
18
18
  authentication: "biometricsOrPasscode",
19
+ } as const;
20
+
21
+ // Rejects with DuplicateItemError when the name has a value.
22
+ await secureStorage.create("identity/encryption", base64Value, options);
23
+
24
+ // Rejects with ItemNotFoundError when the name has no value.
25
+ await secureStorage.replace("identity/encryption", base64Value, {
26
+ ...options,
27
+ prompt: "Confirm it's you to replace your key",
19
28
  });
20
29
 
30
+ // Replaces the value, or creates it when the name has none.
31
+ await secureStorage.set("identity/encryption", base64Value, options);
32
+
21
33
  // string | null
22
34
  const value = await secureStorage.get("identity/encryption", {
23
35
  prompt: "Confirm it's you to link a new device",
@@ -51,17 +63,20 @@ enrolment change as failed authentication, so `get` rejects with
51
63
  `prompt` is the reason shown in the system authentication prompt. Without it,
52
64
  the platform's default prompt is shown.
53
65
 
54
- `set` replaces an existing value in place or adds a new one, so a failed `set`
66
+ `replace` and `set` replace an existing value in place, so a failed write
55
67
  leaves the previous value. On Apple platforms the system asks for the
56
68
  authentication an existing value was saved with before replacing it, as it does
57
- for a read: `set` then shows its `prompt` and needs the app on screen. Android
58
- replaces values without asking.
69
+ for a read: `replace` and `set` then show their `prompt` and need the app on
70
+ screen. Android replaces values without asking.
59
71
 
60
72
  ## Errors
61
73
 
62
74
  | Name | When |
63
75
  |---|---|
64
76
  | `NotSupportedError` | The platform, build or device cannot enforce the options |
77
+ | `DuplicateItemError` | `create` was given a name that has a value |
78
+ | `ItemNotFoundError` | `replace` was given a name that has no value |
79
+ | `MissingEntitlementError` | `keychainGroup` is not one of the app's keychain access groups |
65
80
  | `InvalidStateError` | The requested authentication is not set up on the device |
66
81
  | `NotAllowedError` | The user cancelled or failed authentication, or the device is locked |
67
82
  | `NeedsUIError` | Reading a value saved with `authentication` while the app is off screen on iOS or Android, or replacing one on iOS, where the system cannot show its prompt |
@@ -71,6 +86,25 @@ replaces values without asking.
71
86
 
72
87
  `get` resolves `null` when the name has no stored value.
73
88
 
89
+ ## Keychain access groups
90
+
91
+ On Apple platforms an app can store values in each keychain access group its
92
+ `keychain-access-groups` entitlement lists. `keychainGroup` selects one, as
93
+ `kSecAttrAccessGroup` does in a native keychain query:
94
+
95
+ - `create`, and `set` when the name has no value, store the value in
96
+ `keychainGroup`, or in the first listed group when it is omitted.
97
+ - `get`, `replace`, `delete` and `clear`, and `set` when the name has a value,
98
+ act on `keychainGroup`, or on every group when it is omitted.
99
+
100
+ A name can have a value in more than one group. Without `keychainGroup`, `get`
101
+ resolves one of them, `delete` and `clear` delete all of them, and `replace`
102
+ and `set` reject with `NotAllowedError`, which is how the keychain reports an
103
+ update that matches more than one value (`errSecAuthFailed`).
104
+
105
+ Other platforms have no equivalent and reject `keychainGroup` with
106
+ `NotSupportedError`.
107
+
74
108
  ## Platforms
75
109
 
76
110
  - **iOS:** Keychain items, which survive deleting and reinstalling the app.
@@ -103,6 +137,8 @@ groups, the app's own first, in the app's entitlements file
103
137
 
104
138
  New values go into the app's own group; existing values stay in the shared
105
139
  group even when replaced, so keep `TEAMID.*` while users may still hold them.
140
+ Pass `keychainGroup: "TEAMID.*"` to act on those values only, for example to
141
+ delete one after creating it again in the app's own group.
106
142
 
107
143
  Removing the device passcode or screen lock makes values stored with
108
144
  `authentication` permanently unreadable on Android.
@@ -63,16 +63,25 @@ class TokamakSecureStoragePlugin(
63
63
 
64
64
  override fun call(method: String, arguments: Any?, reply: TokamakPluginReply) {
65
65
  when (method) {
66
+ "create" -> execute(reply) {
67
+ store(request(arguments), Write.CREATE)
68
+ reply(Result.success(null))
69
+ }
70
+ "replace" -> execute(reply) {
71
+ store(request(arguments), Write.REPLACE)
72
+ reply(Result.success(null))
73
+ }
66
74
  "set" -> execute(reply) {
67
- set(requireObject(arguments))
75
+ store(request(arguments), Write.SET)
68
76
  reply(Result.success(null))
69
77
  }
70
- "get" -> execute(reply) { get(requireObject(arguments), reply) }
78
+ "get" -> execute(reply) { get(request(arguments), reply) }
71
79
  "delete" -> execute(reply) {
72
- delete(requireObject(arguments).requireString("name"))
80
+ delete(request(arguments).requireString("name"))
73
81
  reply(Result.success(null))
74
82
  }
75
83
  "clear" -> execute(reply) {
84
+ request(arguments) // Rejects keychainGroup.
76
85
  clear()
77
86
  reply(Result.success(null))
78
87
  }
@@ -80,6 +89,15 @@ class TokamakSecureStoragePlugin(
80
89
  }
81
90
  }
82
91
 
92
+ /** The call's arguments, rejecting the Apple-only `keychainGroup`. */
93
+ private fun request(arguments: Any?): JSONObject {
94
+ val request = requireObject(arguments)
95
+ if (request.optionalString("keychainGroup") != null) {
96
+ throw TokamakPluginError.notSupported("keychainGroup is an Apple keychain feature")
97
+ }
98
+ return request
99
+ }
100
+
83
101
  private fun execute(reply: TokamakPluginReply, operation: () -> Unit) =
84
102
  worker.execute {
85
103
  runCatching(operation).onFailure { reply(Result.failure(pluginError(it))) }
@@ -90,7 +108,7 @@ class TokamakSecureStoragePlugin(
90
108
  runCatching(operation).onFailure { reply(Result.failure(pluginError(it))) }
91
109
  }
92
110
 
93
- private fun set(request: JSONObject) {
111
+ private fun store(request: JSONObject, mode: Write) {
94
112
  val name = request.requireString("name")
95
113
  val value = request.requireString("value")
96
114
  val unlockedDeviceRequired =
@@ -111,7 +129,14 @@ class TokamakSecureStoragePlugin(
111
129
  .setUnlockedDeviceRequired(unlockedDeviceRequired)
112
130
  request.optionalString("authentication")?.let { requireAuthentication(spec, it) }
113
131
  val file = file(name)
114
- val previousAlias = read(file)?.let { alias(it.keyId) }
132
+ val previous = read(file)
133
+ if (previous != null && mode == Write.CREATE) {
134
+ throw TokamakPluginError("DuplicateItemError", "A value is already stored under this name")
135
+ }
136
+ if (previous == null && mode == Write.REPLACE) {
137
+ throw TokamakPluginError("ItemNotFoundError", "No value is stored under this name")
138
+ }
139
+ val previousAlias = previous?.let { alias(it.keyId) }
115
140
  val publicKey = generatePublicKey(spec)
116
141
  try {
117
142
  write(file, seal(keyId, value, publicKey).encode())
@@ -251,6 +276,9 @@ class TokamakSecureStoragePlugin(
251
276
 
252
277
  private fun ByteArray.toHex() = joinToString("") { "%02x".format(it) }
253
278
 
279
+ /** Whether a write requires the name to have no value, a value, or either. */
280
+ private enum class Write { CREATE, REPLACE, SET }
281
+
254
282
  private enum class Authentication(
255
283
  val wireName: String,
256
284
  val keyTypes: Int,
@@ -18,10 +18,6 @@ final class TokamakSecureStoragePlugin: TokamakPlugin {
18
18
  self.host = host
19
19
  }
20
20
 
21
- private static let teamSigningRequired = TokamakPluginError.notSupported(
22
- "Secure storage requires a team-signed build"
23
- )
24
-
25
21
  #if os(macOS)
26
22
  /// macOS grants the data protection keychain only to apps whose provisioning
27
23
  /// profile provides an application identifier.
@@ -68,79 +64,102 @@ final class TokamakSecureStoragePlugin: TokamakPlugin {
68
64
  ) throws(TokamakPluginError) -> Any? {
69
65
  #if os(macOS)
70
66
  // Unentitled reads report errSecItemNotFound rather than a missing entitlement.
71
- guard Self.hasApplicationIdentifier else { throw Self.teamSigningRequired }
67
+ guard Self.hasApplicationIdentifier else {
68
+ throw .notSupported("Secure storage requires a team-signed build")
69
+ }
72
70
  #endif
71
+ let group = try optionalString(arguments, "keychainGroup")
73
72
  switch method {
73
+ case "create":
74
+ try check(add(requiredString(arguments, "name"), group: group, stored(arguments)))
75
+ return nil
76
+ case "replace":
77
+ try check(
78
+ update(
79
+ requiredString(arguments, "name"),
80
+ group: group,
81
+ stored(arguments),
82
+ prompt: optionalString(arguments, "prompt"),
83
+ prompting: prompting
84
+ ))
85
+ return nil
74
86
  case "set":
75
- try set(
76
- requiredString(arguments, "name"),
77
- value: requiredString(arguments, "value"),
78
- readable: requiredString(arguments, "readable"),
79
- thisDeviceOnly: requiredBool(arguments, "thisDeviceOnly"),
80
- authentication: optionalString(arguments, "authentication"),
87
+ let name = try requiredString(arguments, "name")
88
+ let stored = try stored(arguments)
89
+ let status = try update(
90
+ name,
91
+ group: group,
92
+ stored,
81
93
  prompt: optionalString(arguments, "prompt"),
82
94
  prompting: prompting
83
95
  )
96
+ try check(status == errSecItemNotFound ? add(name, group: group, stored) : status)
84
97
  return nil
85
98
  case "get":
86
99
  return try get(
87
100
  requiredString(arguments, "name"),
101
+ group: group,
88
102
  prompt: optionalString(arguments, "prompt"),
89
103
  prompting: prompting
90
104
  )
91
105
  case "delete":
92
- try deleteItems(query(requiredString(arguments, "name")))
106
+ try deleteItems(query(requiredString(arguments, "name"), group: group))
93
107
  return nil
94
108
  case "clear":
95
- try deleteItems(serviceQuery())
109
+ try deleteItems(serviceQuery(group: group))
96
110
  return nil
97
111
  default:
98
112
  throw .notSupported("\(id).\(method) is not supported")
99
113
  }
100
114
  }
101
115
 
102
- /// Updates an existing value in place or adds a new one, so a failure
103
- /// leaves the previous value. Checks without UI first, so only replacing a
104
- /// value saved with authentication needs the system prompt.
105
- private func set(
116
+ /// The value and its protection, as keychain attributes. Access control even
117
+ /// without authentication, so an update removes any the existing value had.
118
+ private func stored(_ arguments: [String: Any]) throws(TokamakPluginError) -> [CFString: Any] {
119
+ let accessibility = try accessibility(
120
+ requiredString(arguments, "readable"),
121
+ thisDeviceOnly: requiredBool(arguments, "thisDeviceOnly")
122
+ )
123
+ return [
124
+ kSecValueData: Data(try requiredString(arguments, "value").utf8),
125
+ kSecAttrAccessControl: try accessControl(
126
+ accessibility, optionalString(arguments, "authentication")),
127
+ ]
128
+ }
129
+
130
+ /// Adds `name`'s value in one keychain call: errSecDuplicateItem when it has one.
131
+ private func add(_ name: String, group: String?, _ stored: [CFString: Any]) -> OSStatus {
132
+ SecItemAdd(query(name, group: group).merging(stored) { _, new in new } as CFDictionary, nil)
133
+ }
134
+
135
+ /// Updates `name`'s value in place, so a failure leaves the previous value:
136
+ /// errSecItemNotFound when it has none. Tries without UI
137
+ /// first, so only a value saved with authentication needs the system prompt.
138
+ private func update(
106
139
  _ name: String,
107
- value: String,
108
- readable: String,
109
- thisDeviceOnly: Bool,
110
- authentication: String?,
140
+ group: String?,
141
+ _ stored: [CFString: Any],
111
142
  prompt: String?,
112
143
  prompting: Result<Void, TokamakPluginError>?
113
- ) throws(TokamakPluginError) {
114
- let accessibility = try accessibility(readable, thisDeviceOnly: thisDeviceOnly)
115
- // Access control even without authentication, so an update removes any the existing value had.
116
- let stored: [CFString: Any] = [
117
- kSecValueData: Data(value.utf8),
118
- kSecAttrAccessControl: try accessControl(accessibility, authentication),
119
- ]
120
- var existing = query(name)
121
- existing[kSecUseAuthenticationContext] = Self.contextWithoutUI()
122
- let status = SecItemCopyMatching(existing as CFDictionary, nil)
123
- if status == errSecItemNotFound {
124
- try check(SecItemAdd(query(name).merging(stored) { _, new in new } as CFDictionary, nil))
125
- return
126
- }
127
- if status == errSecInteractionNotAllowed, let prompting {
128
- try prompting.get()
129
- existing[kSecUseAuthenticationContext] = Self.context(prompt: prompt)
130
- } else {
131
- try check(status)
132
- }
133
- try check(SecItemUpdate(existing as CFDictionary, stored as CFDictionary))
144
+ ) throws(TokamakPluginError) -> OSStatus {
145
+ var item = query(name, group: group)
146
+ item[kSecUseAuthenticationContext] = Self.contextWithoutUI()
147
+ let status = SecItemUpdate(item as CFDictionary, stored as CFDictionary)
148
+ guard status == errSecInteractionNotAllowed, let prompting else { return status }
149
+ try prompting.get()
150
+ item[kSecUseAuthenticationContext] = Self.context(prompt: prompt)
151
+ return SecItemUpdate(item as CFDictionary, stored as CFDictionary)
134
152
  }
135
153
 
136
154
  /// Reads without UI first, so only a value saved with authentication needs
137
155
  /// the app able to show the system prompt.
138
156
  private func get(
139
157
  _ name: String,
158
+ group: String?,
140
159
  prompt: String?,
141
160
  prompting: Result<Void, TokamakPluginError>?
142
161
  ) throws(TokamakPluginError) -> String? {
143
- var item = query(name)
162
+ var item = query(name, group: group)
144
163
  item[kSecReturnData] = true
145
164
  item[kSecUseAuthenticationContext] = Self.contextWithoutUI()
146
165
  var data: CFTypeRef?
@@ -174,7 +193,7 @@ final class TokamakSecureStoragePlugin: TokamakPlugin {
174
193
  }
175
194
  }
176
195
 
177
- /// A context in which reads that need authentication fail with
196
+ /// A context in which keychain calls that need authentication fail with
178
197
  /// `errSecInteractionNotAllowed` rather than show the system prompt.
179
198
  private static func contextWithoutUI() -> LAContext {
180
199
  let context = LAContext()
@@ -190,18 +209,22 @@ final class TokamakSecureStoragePlugin: TokamakPlugin {
190
209
  return context
191
210
  }
192
211
 
193
- /// All of the plugin's items. The data protection keychain is the only
194
- /// keychain on iOS; macOS needs a team-signed build to use it.
195
- private func serviceQuery() -> [CFString: Any] {
196
- [
212
+ /// The plugin's items in keychain access group `group`, or in every group
213
+ /// of the app when nil, where an add uses the app's first group. The data
214
+ /// protection keychain is the only keychain on iOS; macOS needs a
215
+ /// team-signed build to use it.
216
+ private func serviceQuery(group: String?) -> [CFString: Any] {
217
+ var query: [CFString: Any] = [
197
218
  kSecClass: kSecClassGenericPassword,
198
219
  kSecAttrService: service,
199
220
  kSecUseDataProtectionKeychain: true,
200
221
  ]
222
+ query[kSecAttrAccessGroup] = group
223
+ return query
201
224
  }
202
225
 
203
- private func query(_ name: String) -> [CFString: Any] {
204
- serviceQuery().merging([kSecAttrAccount: name]) { _, name in name }
226
+ private func query(_ name: String, group: String?) -> [CFString: Any] {
227
+ serviceQuery(group: group).merging([kSecAttrAccount: name]) { _, name in name }
205
228
  }
206
229
 
207
230
  /// `ThisDeviceOnly` items restore only to the device they were stored on.
@@ -255,8 +278,12 @@ final class TokamakSecureStoragePlugin: TokamakPlugin {
255
278
  guard status != errSecSuccess else { return }
256
279
  let message = SecCopyErrorMessageString(status, nil) as String? ?? "Keychain error \(status)"
257
280
  switch status {
281
+ case errSecDuplicateItem:
282
+ throw TokamakPluginError(name: "DuplicateItemError", message: message)
283
+ case errSecItemNotFound:
284
+ throw TokamakPluginError(name: "ItemNotFoundError", message: message)
258
285
  case errSecMissingEntitlement:
259
- throw Self.teamSigningRequired
286
+ throw TokamakPluginError(name: "MissingEntitlementError", message: message)
260
287
  case errSecUserCanceled, errSecAuthFailed, errSecInteractionNotAllowed:
261
288
  throw TokamakPluginError(name: "NotAllowedError", message: message)
262
289
  default:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokamakdev/plugin-secure-storage",
3
- "version": "0.1.0-beta.100",
3
+ "version": "0.1.0-beta.101",
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.100"
14
+ "@tokamakdev/plugin": "0.1.0-beta.101"
15
15
  },
16
16
  "files": [
17
17
  "android",
package/src/index.ts CHANGED
@@ -6,7 +6,7 @@ export type Readable = "whenUnlocked" | "afterFirstUnlock";
6
6
  /** The authentication a read requires. */
7
7
  export type Authentication = "biometricsOrPasscode" | "biometrics" | "currentBiometrics";
8
8
 
9
- export interface SetOptions {
9
+ export interface CreateOptions {
10
10
  readonly readable: Readable;
11
11
  /** Reads need no authentication when omitted. */
12
12
  readonly authentication?: Authentication;
@@ -15,6 +15,15 @@ export interface SetOptions {
15
15
  * false lets a backup restore it to a new device where the platform supports that.
16
16
  */
17
17
  readonly thisDeviceOnly?: boolean;
18
+ /**
19
+ * Apple platforms: the keychain access group, one of the app's `keychain-access-groups`.
20
+ * When omitted, new values go into the first of them and updates match every group.
21
+ */
22
+ readonly keychainGroup?: string;
23
+ }
24
+
25
+ /** Options for `set` and `replace`. */
26
+ export interface SetOptions extends CreateOptions {
18
27
  /**
19
28
  * The reason shown in the system authentication prompt. Apple platforms ask for the
20
29
  * authentication an existing value was saved with before replacing it.
@@ -25,6 +34,14 @@ export interface SetOptions {
25
34
  export interface GetOptions {
26
35
  /** The reason shown in the system authentication prompt. */
27
36
  readonly prompt?: string;
37
+ /** Apple platforms: the keychain access group to read from. Defaults to all of the app's. */
38
+ readonly keychainGroup?: string;
39
+ }
40
+
41
+ /** Options for `delete` and `clear`. */
42
+ export interface DeleteOptions {
43
+ /** Apple platforms: the keychain access group to delete from. Defaults to all of the app's. */
44
+ readonly keychainGroup?: string;
28
45
  }
29
46
 
30
47
  class SecureStorage extends Plugin {
@@ -32,29 +49,44 @@ class SecureStorage extends Plugin {
32
49
  super("secure-storage");
33
50
  }
34
51
 
52
+ /** Stores a new value; rejects with `DuplicateItemError` when `name` has one. */
53
+ create(name: string, value: string, options: CreateOptions): Promise<void> {
54
+ return this.call("create", stored(name, value, options));
55
+ }
56
+
57
+ /** Replaces the value in place; rejects with `ItemNotFoundError` when `name` has none. */
58
+ replace(name: string, value: string, options: SetOptions): Promise<void> {
59
+ return this.call("replace", { ...stored(name, value, options), prompt: options.prompt ?? null });
60
+ }
61
+
62
+ /** Replaces the value in place, or stores a new one when `name` has none. */
35
63
  set(name: string, value: string, options: SetOptions): Promise<void> {
36
- return this.call("set", {
37
- name,
38
- value,
39
- readable: options.readable,
40
- authentication: options.authentication ?? null,
41
- thisDeviceOnly: options.thisDeviceOnly ?? true,
42
- prompt: options.prompt ?? null,
43
- });
64
+ return this.call("set", { ...stored(name, value, options), prompt: options.prompt ?? null });
44
65
  }
45
66
 
46
67
  get(name: string, options: GetOptions = {}): Promise<string | null> {
47
- return this.call("get", { name, prompt: options.prompt ?? null });
68
+ return this.call("get", { name, prompt: options.prompt ?? null, keychainGroup: options.keychainGroup ?? null });
48
69
  }
49
70
 
50
- delete(name: string): Promise<void> {
51
- return this.call("delete", { name });
71
+ delete(name: string, options: DeleteOptions = {}): Promise<void> {
72
+ return this.call("delete", { name, keychainGroup: options.keychainGroup ?? null });
52
73
  }
53
74
 
54
75
  /** Deletes every stored value. */
55
- clear(): Promise<void> {
56
- return this.call("clear");
76
+ clear(options: DeleteOptions = {}): Promise<void> {
77
+ return this.call("clear", { keychainGroup: options.keychainGroup ?? null });
57
78
  }
58
79
  }
59
80
 
81
+ function stored(name: string, value: string, options: CreateOptions) {
82
+ return {
83
+ name,
84
+ value,
85
+ readable: options.readable,
86
+ authentication: options.authentication ?? null,
87
+ thisDeviceOnly: options.thisDeviceOnly ?? true,
88
+ keychainGroup: options.keychainGroup ?? null,
89
+ };
90
+ }
91
+
60
92
  export const secureStorage = new SecureStorage();