Skip to content

Commit

Permalink
Add support for multiple Valets within the same access group (#297)
Browse files Browse the repository at this point in the history
* 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]>
  • Loading branch information
efirestone and dfed authored Jun 9, 2023
1 parent 317addb commit 89f12b9
Show file tree
Hide file tree
Showing 20 changed files with 256 additions and 55 deletions.
6 changes: 3 additions & 3 deletions Gemfile
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
source 'https://rubygems.org' do
gem 'cocoapods', '~> 1.11.0'
end
source "https://rubygems.org"

gem 'cocoapods', '~> 1.11.0'
5 changes: 1 addition & 4 deletions Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,6 +1,3 @@
GEM
specs:

GEM
remote: https://rubygems.org/
specs:
Expand Down Expand Up @@ -94,7 +91,7 @@ PLATFORMS
ruby

DEPENDENCIES
cocoapods (~> 1.11.0)!
cocoapods (~> 1.11.0)

BUNDLED WITH
2.3.7
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,8 @@ VALValet *const mySharedValet = [VALValet sharedGroupValetWithGroupPrefix:@"grou
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).
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.
### Sharing Secrets Across Devices with iCloud
```swift
Expand All @@ -181,6 +183,8 @@ VALValet *const myCloudValet = [VALValet iCloudValetWithIdentifier:@"Druidia" ac
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.
Shared iCloud Valets can be created with an additional identifier, allowing multiple independently sandboxed keychains to exist within the same iCloud shared group.
### Protecting Secrets with Face ID, Touch ID, or device Passcode
```swift
Expand Down
20 changes: 12 additions & 8 deletions Sources/Valet/Internal/Service.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import Foundation

internal enum Service: CustomStringConvertible, Equatable {
case standard(Identifier, Configuration)
case sharedGroup(SharedGroupIdentifier, Configuration)
case sharedGroup(SharedGroupIdentifier, Identifier?, Configuration)

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

internal static func sharedGroup(with configuration: Configuration, identifier: SharedGroupIdentifier, accessibilityDescription: String) -> String {
"VAL_\(configuration.description)_initWithSharedAccessGroupIdentifier:accessibility:_\(identifier.groupIdentifier)_\(accessibilityDescription)"
internal static func sharedGroup(with configuration: Configuration, groupIdentifier: SharedGroupIdentifier, identifier: Identifier?, accessibilityDescription: String) -> String {
if let identifier = identifier {
return "VAL_\(configuration.description)_initWithSharedAccessGroupIdentifier:accessibility:_\(groupIdentifier.groupIdentifier)_\(identifier)_\(accessibilityDescription)"
} else {
return "VAL_\(configuration.description)_initWithSharedAccessGroupIdentifier:accessibility:_\(groupIdentifier.groupIdentifier)_\(accessibilityDescription)"
}
}

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

case let .sharedGroup(identifier, desiredConfiguration):
baseQuery[kSecAttrAccessGroup as String] = identifier.description
case let .sharedGroup(groupIdentifier, _, desiredConfiguration):
baseQuery[kSecAttrAccessGroup as String] = groupIdentifier.description
configuration = desiredConfiguration

#if os(macOS)
Expand Down Expand Up @@ -107,8 +111,8 @@ internal enum Service: CustomStringConvertible, Equatable {
switch self {
case let .standard(identifier, configuration):
service = Service.standard(with: configuration, identifier: identifier, accessibilityDescription: configuration.accessibility.description)
case let .sharedGroup(identifier, configuration):
service = Service.sharedGroup(with: configuration, identifier: identifier, accessibilityDescription: configuration.accessibility.description)
case let .sharedGroup(groupIdentifier, identifier, configuration):
service = Service.sharedGroup(with: configuration, groupIdentifier: groupIdentifier, identifier: identifier, accessibilityDescription: configuration.accessibility.description)
#if os(macOS)
case let .standardOverride(identifier, _):
service = identifier.description
Expand All @@ -119,7 +123,7 @@ internal enum Service: CustomStringConvertible, Equatable {

switch self {
case let .standard(_, configuration),
let .sharedGroup(_, configuration):
let .sharedGroup(_, _, configuration):
switch configuration {
case .valet, .iCloud:
// Nothing to do here.
Expand Down
4 changes: 2 additions & 2 deletions Sources/Valet/SecureEnclave.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ public final class SecureEnclave {
case let .sharedGroupOverride(identifier, _):
noPromptValet = .sharedGroupValet(withExplicitlySet: identifier, accessibility: .whenPasscodeSetThisDeviceOnly)
#endif
case let .sharedGroup(identifier, _):
noPromptValet = .sharedGroupValet(with: identifier, accessibility: .whenPasscodeSetThisDeviceOnly)
case let .sharedGroup(groupIdentifier, identifier, _):
noPromptValet = .sharedGroupValet(with: groupIdentifier, identifier: identifier, accessibility: .whenPasscodeSetThisDeviceOnly)
}

return noPromptValet.canAccessKeychain()
Expand Down
15 changes: 8 additions & 7 deletions Sources/Valet/SecureEnclaveValet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,13 @@ public final class SecureEnclaveValet: NSObject {
/// - identifier: A non-empty string that must correspond with the value for keychain-access-groups in your Entitlements file.
/// - accessControl: The desired access control for the SecureEnclaveValet.
/// - Returns: A SecureEnclaveValet that reads/writes keychain elements that can be shared across applications written by the same development team.
public class func sharedGroupValet(with identifier: SharedGroupIdentifier, accessControl: SecureEnclaveAccessControl) -> SecureEnclaveValet {
let key = Service.sharedGroup(identifier, .secureEnclave(accessControl)).description as NSString
public class func sharedGroupValet(with groupIdentifier: SharedGroupIdentifier, identifier: Identifier? = nil, accessControl: SecureEnclaveAccessControl) -> SecureEnclaveValet {
let key = Service.sharedGroup(groupIdentifier, identifier, .secureEnclave(accessControl)).description as NSString
if let existingValet = identifierToValetMap.object(forKey: key) {
return existingValet

} else {
let valet = SecureEnclaveValet(sharedAccess: identifier, accessControl: accessControl)
let valet = SecureEnclaveValet(sharedAccess: groupIdentifier, identifier: identifier, accessControl: accessControl)
identifierToValetMap.setObject(valet, forKey: key)
return valet
}
Expand Down Expand Up @@ -80,11 +80,12 @@ public final class SecureEnclaveValet: NSObject {
accessControl: accessControl)
}

private convenience init(sharedAccess groupIdentifier: SharedGroupIdentifier, accessControl: SecureEnclaveAccessControl) {
private convenience init(sharedAccess groupIdentifier: SharedGroupIdentifier, identifier: Identifier? = nil, accessControl: SecureEnclaveAccessControl) {
self.init(
identifier: groupIdentifier.asIdentifier,
service: .sharedGroup(groupIdentifier, .secureEnclave(accessControl)),
accessControl: accessControl)
identifier: identifier ?? groupIdentifier.asIdentifier,
service: .sharedGroup(groupIdentifier, identifier, .secureEnclave(accessControl)),
accessControl: accessControl
)
}

private init(identifier: Identifier, service: Service, accessControl: SecureEnclaveAccessControl) {
Expand Down
12 changes: 6 additions & 6 deletions Sources/Valet/SinglePromptSecureEnclaveValet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,13 @@ public final class SinglePromptSecureEnclaveValet: NSObject {
/// - identifier: A non-empty identifier that must correspond with the value for keychain-access-groups in your Entitlements file.
/// - accessControl: The desired access control for the SinglePromptSecureEnclaveValet.
/// - Returns: A SinglePromptSecureEnclaveValet that reads/writes keychain elements that can be shared across applications written by the same development team.
public class func sharedGroupValet(with identifier: SharedGroupIdentifier, accessControl: SecureEnclaveAccessControl) -> SinglePromptSecureEnclaveValet {
let key = Service.sharedGroup(identifier, .singlePromptSecureEnclave(accessControl)).description as NSString
public class func sharedGroupValet(with groupIdentifier: SharedGroupIdentifier, identifier: Identifier? = nil, accessControl: SecureEnclaveAccessControl) -> SinglePromptSecureEnclaveValet {
let key = Service.sharedGroup(groupIdentifier, identifier, .singlePromptSecureEnclave(accessControl)).description as NSString
if let existingValet = identifierToValetMap.object(forKey: key) {
return existingValet

} else {
let valet = SinglePromptSecureEnclaveValet(sharedAccess: identifier, accessControl: accessControl)
let valet = SinglePromptSecureEnclaveValet(sharedAccess: groupIdentifier, identifier: identifier, accessControl: accessControl)
identifierToValetMap.setObject(valet, forKey: key)
return valet
}
Expand Down Expand Up @@ -86,10 +86,10 @@ public final class SinglePromptSecureEnclaveValet: NSObject {
accessControl: accessControl)
}

private convenience init(sharedAccess groupIdentifier: SharedGroupIdentifier, accessControl: SecureEnclaveAccessControl) {
private convenience init(sharedAccess groupIdentifier: SharedGroupIdentifier, identifier: Identifier? = nil, accessControl: SecureEnclaveAccessControl) {
self.init(
identifier: groupIdentifier.asIdentifier,
service: .sharedGroup(groupIdentifier, .singlePromptSecureEnclave(accessControl)),
identifier: identifier ?? groupIdentifier.asIdentifier,
service: .sharedGroup(groupIdentifier, identifier, .singlePromptSecureEnclave(accessControl)),
accessControl: accessControl)
}

Expand Down
36 changes: 19 additions & 17 deletions Sources/Valet/Valet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,19 +40,21 @@ public final class Valet: NSObject {
}

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

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

#if os(macOS)
Expand Down Expand Up @@ -127,14 +129,14 @@ public final class Valet: NSObject {
}
}

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

} else {
let valet = Valet(sharedAccess: identifier, configuration: configuration)
let valet = Valet(sharedAccess: groupIdentifier, identifier: identifier, configuration: configuration)
identifierToValetMap.setObject(valet, forKey: key)
return valet
}
Expand Down Expand Up @@ -184,10 +186,10 @@ public final class Valet: NSObject {
configuration: configuration)
}

private convenience init(sharedAccess groupIdentifier: SharedGroupIdentifier, configuration: Configuration) {
private convenience init(sharedAccess groupIdentifier: SharedGroupIdentifier, identifier: Identifier?, configuration: Configuration) {
self.init(
identifier: groupIdentifier.asIdentifier,
service: .sharedGroup(groupIdentifier, configuration),
identifier: identifier ?? groupIdentifier.asIdentifier,
service: .sharedGroup(groupIdentifier, identifier, configuration),
configuration: configuration)
}

Expand Down Expand Up @@ -404,8 +406,8 @@ public final class Valet: NSObject {
let accessibilityDescription = "AccessibleAlways"
let serviceAttribute: String
switch service {
case let .sharedGroup(sharedGroupIdentifier, _):
serviceAttribute = Service.sharedGroup(with: configuration, identifier: sharedGroupIdentifier, accessibilityDescription: accessibilityDescription)
case let .sharedGroup(sharedGroupIdentifier, identifier, _):
serviceAttribute = Service.sharedGroup(with: configuration, groupIdentifier: sharedGroupIdentifier, identifier: identifier, accessibilityDescription: accessibilityDescription)
case .standard:
serviceAttribute = Service.standard(with: configuration, identifier: identifier, accessibilityDescription: accessibilityDescription)
#if os(macOS)
Expand Down Expand Up @@ -439,8 +441,8 @@ public final class Valet: NSObject {
let accessibilityDescription = "AccessibleAlwaysThisDeviceOnly"
let serviceAttribute: String
switch service {
case let .sharedGroup(identifier, _):
serviceAttribute = Service.sharedGroup(with: configuration, identifier: identifier, accessibilityDescription: accessibilityDescription)
case let .sharedGroup(groupIdentifier, identifier, _):
serviceAttribute = Service.sharedGroup(with: configuration, groupIdentifier: groupIdentifier, identifier: identifier, accessibilityDescription: accessibilityDescription)
case .standard:
serviceAttribute = Service.standard(with: configuration, identifier: identifier, accessibilityDescription: accessibilityDescription)
#if os(macOS)
Expand Down Expand Up @@ -723,9 +725,9 @@ internal extension Valet {
}
}

class func permutations(with identifier: SharedGroupIdentifier) -> [Valet] {
class func permutations(with groupIdentifier: SharedGroupIdentifier, identifier: Identifier? = nil) -> [Valet] {
Accessibility.allCases.map { accessibility in
.sharedGroupValet(with: identifier, accessibility: accessibility)
.sharedGroupValet(with: groupIdentifier, identifier: identifier, accessibility: accessibility)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ internal extension Valet {

var legacyIdentifier: String {
switch service {
case let .sharedGroup(sharedAccessGroupIdentifier, _):
case let .sharedGroup(sharedAccessGroupIdentifier, _, _):
return sharedAccessGroupIdentifier.groupIdentifier
case let .standard(identifier, _):
return identifier.description
Expand Down
18 changes: 18 additions & 0 deletions Tests/ValetIntegrationTests/SecureEnclaveIntegrationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,24 @@ class SecureEnclaveIntegrationTests: XCTestCase
XCTAssertEqual(error as? KeychainError, .itemNotFound)
}
}

func test_secureEnclaveSharedGroupValetsWithDifferingIdentifiers_canNotAccessSameData() throws
{
guard testEnvironmentIsSigned() && testEnvironmentSupportsWhenPasscodeSet() else {
return
}

let valet1 = SecureEnclaveValet.sharedGroupValet(with: Valet.sharedAccessGroupIdentifier, identifier: Identifier(nonEmpty: "valet1"), accessControl: .devicePasscode)
let valet2 = SecureEnclaveValet.sharedGroupValet(with: Valet.sharedAccessGroupIdentifier, identifier: Identifier(nonEmpty: "valet2"), accessControl: .devicePasscode)

try valet1.setString(passcode, forKey: key)

XCTAssertNotEqual(valet1, valet2)
XCTAssertEqual(passcode, try valet1.string(forKey: key, withPrompt: ""))
XCTAssertThrowsError(try valet2.string(forKey: key, withPrompt: "")) { error in
XCTAssertEqual(error as? KeychainError, .itemNotFound)
}
}

// MARK: canAccessKeychain

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,24 @@ class SinglePromptSecureEnclaveIntegrationTests: XCTestCase
}
}

func test_SinglePromptSecureEnclaveValetsWithDifferingIdentifiers_canNotAccessSameData() throws
{
guard testEnvironmentIsSigned() && testEnvironmentSupportsWhenPasscodeSet() else {
return
}

let valet1 = SinglePromptSecureEnclaveValet.sharedGroupValet(with: Valet.sharedAppGroupIdentifier, identifier: Identifier(nonEmpty: "valet1"), accessControl: .devicePasscode)
let valet2 = SinglePromptSecureEnclaveValet.sharedGroupValet(with: Valet.sharedAppGroupIdentifier, identifier: Identifier(nonEmpty: "valet2"), accessControl: .devicePasscode)

try valet1.setString(passcode, forKey: key)

XCTAssertNotEqual(valet1, valet2)
XCTAssertEqual(passcode, try valet1.string(forKey: key, withPrompt: ""))
XCTAssertThrowsError(try valet2.string(forKey: key, withPrompt: "")) { error in
XCTAssertEqual(error as? KeychainError, .itemNotFound)
}
}

// MARK: allKeys

func test_allKeys() throws
Expand Down
Loading

0 comments on commit 89f12b9

Please sign in to comment.