Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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