Skip to content

Commit 89f12b9

Browse files
efirestonedfed
andauthored
Add support for multiple Valets within the same access group (#297)
* Fix warning about missing global source in Gemfile * Commit Xcode-generated changes These changes happened automatically as a result of using a newer Xcode version * Add support for multiple Valets within the same access group Previously, the shared access group identifier was used for both the access group itself, as well as the uniqueness identifier for the given Valet. This adds an optional additional `identifier` parameter that can be specified when creating a shared access group Valet. The identifier adds an additional element of uniqueness, so two Valets with the same shared access group can exist, and their data will not overlap, so long as they have different identifiers. The default for this value is `nil`, which keeps the existing behavior for full backward compatibility. * Bump version to 4.2.0 --------- Co-authored-by: Dan Federman <[email protected]>
1 parent 317addb commit 89f12b9

20 files changed

+256
-55
lines changed

Gemfile

+3-3
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
source 'https://rubygems.org' do
2-
gem 'cocoapods', '~> 1.11.0'
3-
end
1+
source "https://rubygems.org"
2+
3+
gem 'cocoapods', '~> 1.11.0'

Gemfile.lock

+1-4
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,3 @@
1-
GEM
2-
specs:
3-
41
GEM
52
remote: https://rubygems.org/
63
specs:
@@ -94,7 +91,7 @@ PLATFORMS
9491
ruby
9592

9693
DEPENDENCIES
97-
cocoapods (~> 1.11.0)!
94+
cocoapods (~> 1.11.0)
9895

9996
BUNDLED WITH
10097
2.3.7

README.md

+4
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,8 @@ VALValet *const mySharedValet = [VALValet sharedGroupValetWithGroupPrefix:@"grou
169169
170170
This instance can be used to store and retrieve data securely across any app written by the same developer that has `group.Druidia` set as a value for the `com.apple.security.application-groups` key in the app’s `Entitlements`. This Valet is accessible when the device is unlocked. Note that `myValet` and `mySharedValet` cannot read or modify one another’s values because the two Valets were created with different initializers. All Valet types can share secrets across applications written by the same developer by using the `sharedGroupValet` initializer. Note that on macOS, the `groupPrefix` [must be the App ID prefix](https://developer.apple.com/documentation/bundleresources/entitlements/com_apple_security_application-groups#discussion).
171171
172+
As with Valets, shared iCloud Valets can be created with an additional identifier, allowing multiple independently sandboxed keychains to exist within the same shared group.
173+
172174
### Sharing Secrets Across Devices with iCloud
173175
174176
```swift
@@ -181,6 +183,8 @@ VALValet *const myCloudValet = [VALValet iCloudValetWithIdentifier:@"Druidia" ac
181183
182184
This instance can be used to store and retrieve data that can be retrieved by this app on other devices logged into the same iCloud account with iCloud Keychain enabled. If iCloud Keychain is not enabled on this device, secrets can still be read and written, but will not sync to other devices. Note that `myCloudValet` can not read or modify values in either `myValet` or `mySharedValet` because `myCloudValet` was created a different initializer.
183185
186+
Shared iCloud Valets can be created with an additional identifier, allowing multiple independently sandboxed keychains to exist within the same iCloud shared group.
187+
184188
### Protecting Secrets with Face ID, Touch ID, or device Passcode
185189
186190
```swift

Sources/Valet/Internal/Service.swift

+12-8
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import Foundation
1919

2020
internal enum Service: CustomStringConvertible, Equatable {
2121
case standard(Identifier, Configuration)
22-
case sharedGroup(SharedGroupIdentifier, Configuration)
22+
case sharedGroup(SharedGroupIdentifier, Identifier?, Configuration)
2323

2424
#if os(macOS)
2525
case standardOverride(service: Identifier, Configuration)
@@ -44,8 +44,12 @@ internal enum Service: CustomStringConvertible, Equatable {
4444
"VAL_\(configuration.description)_initWithIdentifier:accessibility:_\(identifier)_\(accessibilityDescription)"
4545
}
4646

47-
internal static func sharedGroup(with configuration: Configuration, identifier: SharedGroupIdentifier, accessibilityDescription: String) -> String {
48-
"VAL_\(configuration.description)_initWithSharedAccessGroupIdentifier:accessibility:_\(identifier.groupIdentifier)_\(accessibilityDescription)"
47+
internal static func sharedGroup(with configuration: Configuration, groupIdentifier: SharedGroupIdentifier, identifier: Identifier?, accessibilityDescription: String) -> String {
48+
if let identifier = identifier {
49+
return "VAL_\(configuration.description)_initWithSharedAccessGroupIdentifier:accessibility:_\(groupIdentifier.groupIdentifier)_\(identifier)_\(accessibilityDescription)"
50+
} else {
51+
return "VAL_\(configuration.description)_initWithSharedAccessGroupIdentifier:accessibility:_\(groupIdentifier.groupIdentifier)_\(accessibilityDescription)"
52+
}
4953
}
5054

5155
internal static func sharedGroup(with configuration: Configuration, explicitlySetIdentifier identifier: Identifier, accessibilityDescription: String) -> String {
@@ -69,8 +73,8 @@ internal enum Service: CustomStringConvertible, Equatable {
6973
case let .standard(_, desiredConfiguration):
7074
configuration = desiredConfiguration
7175

72-
case let .sharedGroup(identifier, desiredConfiguration):
73-
baseQuery[kSecAttrAccessGroup as String] = identifier.description
76+
case let .sharedGroup(groupIdentifier, _, desiredConfiguration):
77+
baseQuery[kSecAttrAccessGroup as String] = groupIdentifier.description
7478
configuration = desiredConfiguration
7579

7680
#if os(macOS)
@@ -107,8 +111,8 @@ internal enum Service: CustomStringConvertible, Equatable {
107111
switch self {
108112
case let .standard(identifier, configuration):
109113
service = Service.standard(with: configuration, identifier: identifier, accessibilityDescription: configuration.accessibility.description)
110-
case let .sharedGroup(identifier, configuration):
111-
service = Service.sharedGroup(with: configuration, identifier: identifier, accessibilityDescription: configuration.accessibility.description)
114+
case let .sharedGroup(groupIdentifier, identifier, configuration):
115+
service = Service.sharedGroup(with: configuration, groupIdentifier: groupIdentifier, identifier: identifier, accessibilityDescription: configuration.accessibility.description)
112116
#if os(macOS)
113117
case let .standardOverride(identifier, _):
114118
service = identifier.description
@@ -119,7 +123,7 @@ internal enum Service: CustomStringConvertible, Equatable {
119123

120124
switch self {
121125
case let .standard(_, configuration),
122-
let .sharedGroup(_, configuration):
126+
let .sharedGroup(_, _, configuration):
123127
switch configuration {
124128
case .valet, .iCloud:
125129
// Nothing to do here.

Sources/Valet/SecureEnclave.swift

+2-2
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,8 @@ public final class SecureEnclave {
3838
case let .sharedGroupOverride(identifier, _):
3939
noPromptValet = .sharedGroupValet(withExplicitlySet: identifier, accessibility: .whenPasscodeSetThisDeviceOnly)
4040
#endif
41-
case let .sharedGroup(identifier, _):
42-
noPromptValet = .sharedGroupValet(with: identifier, accessibility: .whenPasscodeSetThisDeviceOnly)
41+
case let .sharedGroup(groupIdentifier, identifier, _):
42+
noPromptValet = .sharedGroupValet(with: groupIdentifier, identifier: identifier, accessibility: .whenPasscodeSetThisDeviceOnly)
4343
}
4444

4545
return noPromptValet.canAccessKeychain()

Sources/Valet/SecureEnclaveValet.swift

+8-7
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,13 @@ public final class SecureEnclaveValet: NSObject {
4343
/// - identifier: A non-empty string that must correspond with the value for keychain-access-groups in your Entitlements file.
4444
/// - accessControl: The desired access control for the SecureEnclaveValet.
4545
/// - Returns: A SecureEnclaveValet that reads/writes keychain elements that can be shared across applications written by the same development team.
46-
public class func sharedGroupValet(with identifier: SharedGroupIdentifier, accessControl: SecureEnclaveAccessControl) -> SecureEnclaveValet {
47-
let key = Service.sharedGroup(identifier, .secureEnclave(accessControl)).description as NSString
46+
public class func sharedGroupValet(with groupIdentifier: SharedGroupIdentifier, identifier: Identifier? = nil, accessControl: SecureEnclaveAccessControl) -> SecureEnclaveValet {
47+
let key = Service.sharedGroup(groupIdentifier, identifier, .secureEnclave(accessControl)).description as NSString
4848
if let existingValet = identifierToValetMap.object(forKey: key) {
4949
return existingValet
5050

5151
} else {
52-
let valet = SecureEnclaveValet(sharedAccess: identifier, accessControl: accessControl)
52+
let valet = SecureEnclaveValet(sharedAccess: groupIdentifier, identifier: identifier, accessControl: accessControl)
5353
identifierToValetMap.setObject(valet, forKey: key)
5454
return valet
5555
}
@@ -80,11 +80,12 @@ public final class SecureEnclaveValet: NSObject {
8080
accessControl: accessControl)
8181
}
8282

83-
private convenience init(sharedAccess groupIdentifier: SharedGroupIdentifier, accessControl: SecureEnclaveAccessControl) {
83+
private convenience init(sharedAccess groupIdentifier: SharedGroupIdentifier, identifier: Identifier? = nil, accessControl: SecureEnclaveAccessControl) {
8484
self.init(
85-
identifier: groupIdentifier.asIdentifier,
86-
service: .sharedGroup(groupIdentifier, .secureEnclave(accessControl)),
87-
accessControl: accessControl)
85+
identifier: identifier ?? groupIdentifier.asIdentifier,
86+
service: .sharedGroup(groupIdentifier, identifier, .secureEnclave(accessControl)),
87+
accessControl: accessControl
88+
)
8889
}
8990

9091
private init(identifier: Identifier, service: Service, accessControl: SecureEnclaveAccessControl) {

Sources/Valet/SinglePromptSecureEnclaveValet.swift

+6-6
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,13 @@ public final class SinglePromptSecureEnclaveValet: NSObject {
4949
/// - identifier: A non-empty identifier that must correspond with the value for keychain-access-groups in your Entitlements file.
5050
/// - accessControl: The desired access control for the SinglePromptSecureEnclaveValet.
5151
/// - Returns: A SinglePromptSecureEnclaveValet that reads/writes keychain elements that can be shared across applications written by the same development team.
52-
public class func sharedGroupValet(with identifier: SharedGroupIdentifier, accessControl: SecureEnclaveAccessControl) -> SinglePromptSecureEnclaveValet {
53-
let key = Service.sharedGroup(identifier, .singlePromptSecureEnclave(accessControl)).description as NSString
52+
public class func sharedGroupValet(with groupIdentifier: SharedGroupIdentifier, identifier: Identifier? = nil, accessControl: SecureEnclaveAccessControl) -> SinglePromptSecureEnclaveValet {
53+
let key = Service.sharedGroup(groupIdentifier, identifier, .singlePromptSecureEnclave(accessControl)).description as NSString
5454
if let existingValet = identifierToValetMap.object(forKey: key) {
5555
return existingValet
5656

5757
} else {
58-
let valet = SinglePromptSecureEnclaveValet(sharedAccess: identifier, accessControl: accessControl)
58+
let valet = SinglePromptSecureEnclaveValet(sharedAccess: groupIdentifier, identifier: identifier, accessControl: accessControl)
5959
identifierToValetMap.setObject(valet, forKey: key)
6060
return valet
6161
}
@@ -86,10 +86,10 @@ public final class SinglePromptSecureEnclaveValet: NSObject {
8686
accessControl: accessControl)
8787
}
8888

89-
private convenience init(sharedAccess groupIdentifier: SharedGroupIdentifier, accessControl: SecureEnclaveAccessControl) {
89+
private convenience init(sharedAccess groupIdentifier: SharedGroupIdentifier, identifier: Identifier? = nil, accessControl: SecureEnclaveAccessControl) {
9090
self.init(
91-
identifier: groupIdentifier.asIdentifier,
92-
service: .sharedGroup(groupIdentifier, .singlePromptSecureEnclave(accessControl)),
91+
identifier: identifier ?? groupIdentifier.asIdentifier,
92+
service: .sharedGroup(groupIdentifier, identifier, .singlePromptSecureEnclave(accessControl)),
9393
accessControl: accessControl)
9494
}
9595

Sources/Valet/Valet.swift

+19-17
Original file line numberDiff line numberDiff line change
@@ -40,19 +40,21 @@ public final class Valet: NSObject {
4040
}
4141

4242
/// - Parameters:
43-
/// - identifier: The identifier for the Valet's shared access group. Must correspond with the value for keychain-access-groups in your Entitlements file.
43+
/// - groupIdentifier: The identifier for the Valet's shared access group. Must correspond with the value for keychain-access-groups in your Entitlements file.
44+
/// - identifier: An optional additional uniqueness identifier. Using this identifier allows for the creation of separate, sandboxed Valets within the same shared access group.
4445
/// - accessibility: The desired accessibility for the Valet.
4546
/// - Returns: A Valet that reads/writes keychain elements that can be shared across applications written by the same development team.
46-
public class func sharedGroupValet(with identifier: SharedGroupIdentifier, accessibility: Accessibility) -> Valet {
47-
findOrCreate(identifier, configuration: .valet(accessibility))
47+
public class func sharedGroupValet(
48+
with groupIdentifier: SharedGroupIdentifier, identifier: Identifier? = nil, accessibility: Accessibility) -> Valet {
49+
findOrCreate(groupIdentifier, identifier: identifier, configuration: .valet(accessibility))
4850
}
4951

5052
/// - Parameters:
5153
/// - identifier: The identifier for the Valet's shared access group. Must correspond with the value for keychain-access-groups in your Entitlements file.
5254
/// - accessibility: The desired accessibility for the Valet.
5355
/// - Returns: A Valet (synchronized with iCloud) that reads/writes keychain elements that can be shared across applications written by the same development team.
54-
public class func iCloudSharedGroupValet(with identifier: SharedGroupIdentifier, accessibility: CloudAccessibility) -> Valet {
55-
findOrCreate(identifier, configuration: .iCloud(accessibility))
56+
public class func iCloudSharedGroupValet(with groupIdentifier: SharedGroupIdentifier, identifier: Identifier? = nil, accessibility: CloudAccessibility) -> Valet {
57+
findOrCreate(groupIdentifier, identifier: identifier, configuration: .iCloud(accessibility))
5658
}
5759

5860
#if os(macOS)
@@ -127,14 +129,14 @@ public final class Valet: NSObject {
127129
}
128130
}
129131

130-
private class func findOrCreate(_ identifier: SharedGroupIdentifier, configuration: Configuration) -> Valet {
131-
let service: Service = .sharedGroup(identifier, configuration)
132+
private class func findOrCreate(_ groupIdentifier: SharedGroupIdentifier, identifier: Identifier?, configuration: Configuration) -> Valet {
133+
let service: Service = .sharedGroup(groupIdentifier, identifier, configuration)
132134
let key = service.description as NSString
133135
if let existingValet = identifierToValetMap.object(forKey: key) {
134136
return existingValet
135137

136138
} else {
137-
let valet = Valet(sharedAccess: identifier, configuration: configuration)
139+
let valet = Valet(sharedAccess: groupIdentifier, identifier: identifier, configuration: configuration)
138140
identifierToValetMap.setObject(valet, forKey: key)
139141
return valet
140142
}
@@ -184,10 +186,10 @@ public final class Valet: NSObject {
184186
configuration: configuration)
185187
}
186188

187-
private convenience init(sharedAccess groupIdentifier: SharedGroupIdentifier, configuration: Configuration) {
189+
private convenience init(sharedAccess groupIdentifier: SharedGroupIdentifier, identifier: Identifier?, configuration: Configuration) {
188190
self.init(
189-
identifier: groupIdentifier.asIdentifier,
190-
service: .sharedGroup(groupIdentifier, configuration),
191+
identifier: identifier ?? groupIdentifier.asIdentifier,
192+
service: .sharedGroup(groupIdentifier, identifier, configuration),
191193
configuration: configuration)
192194
}
193195

@@ -404,8 +406,8 @@ public final class Valet: NSObject {
404406
let accessibilityDescription = "AccessibleAlways"
405407
let serviceAttribute: String
406408
switch service {
407-
case let .sharedGroup(sharedGroupIdentifier, _):
408-
serviceAttribute = Service.sharedGroup(with: configuration, identifier: sharedGroupIdentifier, accessibilityDescription: accessibilityDescription)
409+
case let .sharedGroup(sharedGroupIdentifier, identifier, _):
410+
serviceAttribute = Service.sharedGroup(with: configuration, groupIdentifier: sharedGroupIdentifier, identifier: identifier, accessibilityDescription: accessibilityDescription)
409411
case .standard:
410412
serviceAttribute = Service.standard(with: configuration, identifier: identifier, accessibilityDescription: accessibilityDescription)
411413
#if os(macOS)
@@ -439,8 +441,8 @@ public final class Valet: NSObject {
439441
let accessibilityDescription = "AccessibleAlwaysThisDeviceOnly"
440442
let serviceAttribute: String
441443
switch service {
442-
case let .sharedGroup(identifier, _):
443-
serviceAttribute = Service.sharedGroup(with: configuration, identifier: identifier, accessibilityDescription: accessibilityDescription)
444+
case let .sharedGroup(groupIdentifier, identifier, _):
445+
serviceAttribute = Service.sharedGroup(with: configuration, groupIdentifier: groupIdentifier, identifier: identifier, accessibilityDescription: accessibilityDescription)
444446
case .standard:
445447
serviceAttribute = Service.standard(with: configuration, identifier: identifier, accessibilityDescription: accessibilityDescription)
446448
#if os(macOS)
@@ -723,9 +725,9 @@ internal extension Valet {
723725
}
724726
}
725727

726-
class func permutations(with identifier: SharedGroupIdentifier) -> [Valet] {
728+
class func permutations(with groupIdentifier: SharedGroupIdentifier, identifier: Identifier? = nil) -> [Valet] {
727729
Accessibility.allCases.map { accessibility in
728-
.sharedGroupValet(with: identifier, accessibility: accessibility)
730+
.sharedGroupValet(with: groupIdentifier, identifier: identifier, accessibility: accessibility)
729731
}
730732
}
731733

Tests/ValetIntegrationTests/BackwardsCompatibilityTests/ValetBackwardsCompatibilityTests.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ internal extension Valet {
2727

2828
var legacyIdentifier: String {
2929
switch service {
30-
case let .sharedGroup(sharedAccessGroupIdentifier, _):
30+
case let .sharedGroup(sharedAccessGroupIdentifier, _, _):
3131
return sharedAccessGroupIdentifier.groupIdentifier
3232
case let .standard(identifier, _):
3333
return identifier.description

Tests/ValetIntegrationTests/SecureEnclaveIntegrationTests.swift

+18
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,24 @@ class SecureEnclaveIntegrationTests: XCTestCase
6969
XCTAssertEqual(error as? KeychainError, .itemNotFound)
7070
}
7171
}
72+
73+
func test_secureEnclaveSharedGroupValetsWithDifferingIdentifiers_canNotAccessSameData() throws
74+
{
75+
guard testEnvironmentIsSigned() && testEnvironmentSupportsWhenPasscodeSet() else {
76+
return
77+
}
78+
79+
let valet1 = SecureEnclaveValet.sharedGroupValet(with: Valet.sharedAccessGroupIdentifier, identifier: Identifier(nonEmpty: "valet1"), accessControl: .devicePasscode)
80+
let valet2 = SecureEnclaveValet.sharedGroupValet(with: Valet.sharedAccessGroupIdentifier, identifier: Identifier(nonEmpty: "valet2"), accessControl: .devicePasscode)
81+
82+
try valet1.setString(passcode, forKey: key)
83+
84+
XCTAssertNotEqual(valet1, valet2)
85+
XCTAssertEqual(passcode, try valet1.string(forKey: key, withPrompt: ""))
86+
XCTAssertThrowsError(try valet2.string(forKey: key, withPrompt: "")) { error in
87+
XCTAssertEqual(error as? KeychainError, .itemNotFound)
88+
}
89+
}
7290

7391
// MARK: canAccessKeychain
7492

Tests/ValetIntegrationTests/SinglePromptSecureEnclaveIntegrationTests.swift

+18
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,24 @@ class SinglePromptSecureEnclaveIntegrationTests: XCTestCase
7474
}
7575
}
7676

77+
func test_SinglePromptSecureEnclaveValetsWithDifferingIdentifiers_canNotAccessSameData() throws
78+
{
79+
guard testEnvironmentIsSigned() && testEnvironmentSupportsWhenPasscodeSet() else {
80+
return
81+
}
82+
83+
let valet1 = SinglePromptSecureEnclaveValet.sharedGroupValet(with: Valet.sharedAppGroupIdentifier, identifier: Identifier(nonEmpty: "valet1"), accessControl: .devicePasscode)
84+
let valet2 = SinglePromptSecureEnclaveValet.sharedGroupValet(with: Valet.sharedAppGroupIdentifier, identifier: Identifier(nonEmpty: "valet2"), accessControl: .devicePasscode)
85+
86+
try valet1.setString(passcode, forKey: key)
87+
88+
XCTAssertNotEqual(valet1, valet2)
89+
XCTAssertEqual(passcode, try valet1.string(forKey: key, withPrompt: ""))
90+
XCTAssertThrowsError(try valet2.string(forKey: key, withPrompt: "")) { error in
91+
XCTAssertEqual(error as? KeychainError, .itemNotFound)
92+
}
93+
}
94+
7795
// MARK: allKeys
7896

7997
func test_allKeys() throws

0 commit comments

Comments
 (0)