mirror of
https://github.com/gravitational/teleport.git
synced 2026-10-11 22:49:54 +00:00
iOS: Device Trust credential storage (#68777)
* Initial secure storage implementation * Added base infrastructure for Device Trust Credential Demo * Add the concept of a "location" for storing device trust credentials (2 options, "app" or "demo") * Add view + view model that demos the lifecycle of the device trust credential * Improve the names of various device trust operations to better reflect their actual purpose * Improve the user-facing copy of the secure enclave demo * Improve doc comment of `.emptyChallenge` error
This commit is contained in:
@@ -21,4 +21,7 @@ import DependenciesMacros
|
||||
extension DependencyValues {
|
||||
@DependencyEntry(liveValue: EnrollClient.liveValue)
|
||||
nonisolated var enrollClient = EnrollClient()
|
||||
|
||||
@DependencyEntry(liveValue: DeviceTrustCredentialClient.liveValue)
|
||||
nonisolated var deviceTrustCredentialClient = DeviceTrustCredentialClient()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
// Teleport
|
||||
// Copyright (C) 2026 Gravitational, Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see http://www.gnu.org/licenses/
|
||||
|
||||
import Dependencies
|
||||
import DependenciesMacros
|
||||
import Foundation
|
||||
|
||||
/// Provides the Device Trust credential operations needed by enrollment and authentication ceremonies.
|
||||
///
|
||||
/// Feature tests should override this dependency directly. They do not need to imitate Keychain or Secure Enclave.
|
||||
@DependencyClient
|
||||
struct DeviceTrustCredentialClient {
|
||||
/// Enrollment operation: loads the existing credential or creates one only when it is genuinely absent.
|
||||
var loadOrCreate: @Sendable () throws -> DeviceTrustCredential
|
||||
|
||||
/// Authentication operation: loads the existing credential without ever creating a replacement.
|
||||
var load: @Sendable () throws -> DeviceTrustCredential
|
||||
|
||||
/// Signs the raw Teleport challenge after a user-presence check and returns an ASN.1 DER ECDSA signature.
|
||||
///
|
||||
/// Callers should treat ``DeviceTrustCredentialError/signingAuthorizationCancelled`` as a no-op. All other errors
|
||||
/// describe an operation that failed and should follow the feature's normal error handling.
|
||||
var signChallenge: @Sendable (
|
||||
_ challenge: Data,
|
||||
_ purpose: DeviceTrustChallengePurpose,
|
||||
) async throws -> Data
|
||||
}
|
||||
|
||||
// MARK: - Live Implementation
|
||||
|
||||
extension DeviceTrustCredentialClient {
|
||||
static let liveValue = value(location: .app)
|
||||
|
||||
static func value(location: DeviceTrustCredentialKeychain.Location) -> Self {
|
||||
let store = SecureEnclaveCredentialStore(
|
||||
keychain: DeviceTrustCredentialKeychain(location: location),
|
||||
)
|
||||
return Self(
|
||||
loadOrCreate: {
|
||||
try store.loadOrCreate()
|
||||
},
|
||||
load: {
|
||||
try store.loadExistingCredential()
|
||||
},
|
||||
signChallenge: { challenge, purpose in
|
||||
try await store.signChallenge(challenge, purpose: purpose)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Supporting Types
|
||||
|
||||
/// The public portion of a Device Trust credential.
|
||||
///
|
||||
/// The private key is deliberately absent. It remains in the Secure Enclave and can only be used through
|
||||
/// ``DeviceTrustCredentialClient/signChallenge``.
|
||||
struct DeviceTrustCredential: Equatable {
|
||||
/// An opaque identifier generated when the credential is first created.
|
||||
let id: String
|
||||
|
||||
/// The P-256 public key encoded as PKIX ASN.1 DER for Teleport's enrollment protocol.
|
||||
let publicKeyDER: Data
|
||||
}
|
||||
|
||||
/// Identifies why Teleport needs a signature so the system user-presence prompt can explain the request.
|
||||
enum DeviceTrustChallengePurpose {
|
||||
case enrollment
|
||||
case authentication
|
||||
}
|
||||
|
||||
/// Stable failures that callers can turn into user-facing recovery actions.
|
||||
///
|
||||
/// Apple framework errors are mapped here so `CFError`, `NSError`, and other non-Sendable values do not escape the
|
||||
/// dependency boundary.
|
||||
enum DeviceTrustCredentialError: Error, Equatable {
|
||||
/// The current device cannot create or use Secure Enclave keys.
|
||||
case secureEnclaveUnavailable
|
||||
|
||||
/// Authentication asked for a credential, but this app has not created one.
|
||||
case notFound
|
||||
|
||||
/// Keychain contained a record that could not be decoded or restored by this Secure Enclave.
|
||||
case invalidStoredCredential
|
||||
|
||||
/// Caller of `signChallenge` supplied no bytes to sign.
|
||||
case emptyChallenge
|
||||
|
||||
/// The Secure Enclave could not create a new signing key.
|
||||
case keyCreationFailed
|
||||
|
||||
/// Security.framework could not construct the access-control policy used by the key.
|
||||
case accessControlCreationFailed
|
||||
|
||||
/// The user or system cancelled the presence prompt.
|
||||
case signingAuthorizationCancelled
|
||||
|
||||
/// The user-presence check failed without being cancelled.
|
||||
case signingAuthorizationFailed
|
||||
|
||||
/// CryptoKit could not sign the challenge with the stored Secure Enclave key.
|
||||
case signingFailed
|
||||
|
||||
/// Security.framework returned an error while accessing Keychain.
|
||||
case keychain(status: Int32)
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
// Teleport
|
||||
// Copyright (C) 2026 Gravitational, Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see http://www.gnu.org/licenses/
|
||||
|
||||
import Foundation
|
||||
import OSLog
|
||||
import Security
|
||||
|
||||
/// The versioned value stored as one Keychain item.
|
||||
///
|
||||
/// Keeping the credential ID and encrypted key representation in a single record ensures they are created and loaded
|
||||
/// together. `privateKeyRepresentation` is an opaque, encrypted Secure Enclave handle—not raw private-key bytes.
|
||||
struct StoredDeviceTrustCredential: Codable {
|
||||
fileprivate static let currentVersion = 1
|
||||
|
||||
fileprivate let version: Int
|
||||
let id: String
|
||||
let privateKeyRepresentation: Data
|
||||
|
||||
init(id: String, privateKeyRepresentation: Data) {
|
||||
self.version = Self.currentVersion
|
||||
self.id = id
|
||||
self.privateKeyRepresentation = privateKeyRepresentation
|
||||
}
|
||||
}
|
||||
|
||||
/// Persists the Device Trust credential record in the Verify app's default Keychain access group.
|
||||
///
|
||||
/// This type knows nothing about signing or key creation. Its entire responsibility is to atomically load or insert
|
||||
/// the opaque record used by ``SecureEnclaveCredentialStore``.
|
||||
struct DeviceTrustCredentialKeychain {
|
||||
/// A fixed Keychain identity for a Device Trust credential.
|
||||
///
|
||||
/// The private initializer prevents callers from inventing new storage locations while still allowing the app and
|
||||
/// debug demo to use independent credentials.
|
||||
struct Location {
|
||||
fileprivate let service: String
|
||||
fileprivate let account: String
|
||||
|
||||
static let app = Self(
|
||||
service: "com.gravitational.teleport.verify.device-trust",
|
||||
account: "credential.v1",
|
||||
)
|
||||
|
||||
#if DEBUG
|
||||
static let demo = Self(
|
||||
service: "com.gravitational.teleport.verify.device-trust.demo",
|
||||
account: "credential.demo.v1",
|
||||
)
|
||||
#endif
|
||||
|
||||
private init(service: String, account: String) {
|
||||
self.service = service
|
||||
self.account = account
|
||||
}
|
||||
}
|
||||
|
||||
private let location: Location
|
||||
private let logger = Logger.forType(DeviceTrustCredentialKeychain.self)
|
||||
|
||||
init(location: Location) {
|
||||
self.location = location
|
||||
}
|
||||
|
||||
/// Loads and validates the existing versioned record.
|
||||
func load() throws -> StoredDeviceTrustCredential {
|
||||
var query = baseQuery
|
||||
query[kSecReturnData] = true
|
||||
query[kSecMatchLimit] = kSecMatchLimitOne
|
||||
|
||||
var item: CFTypeRef? = nil
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &item)
|
||||
switch status {
|
||||
case errSecSuccess:
|
||||
guard let data = item as? Data else {
|
||||
throw DeviceTrustCredentialError.invalidStoredCredential
|
||||
}
|
||||
|
||||
return try decode(data)
|
||||
|
||||
case errSecItemNotFound:
|
||||
throw DeviceTrustCredentialError.notFound
|
||||
|
||||
default:
|
||||
logger.error("Could not load Device Trust credential from Keychain: \(status)")
|
||||
throw DeviceTrustCredentialError.keychain(status: status)
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds a new record without replacing an existing credential.
|
||||
///
|
||||
/// `errSecDuplicateItem` is intentionally returned to the caller, which reloads the record that won a concurrent
|
||||
/// creation race.
|
||||
func insert(_ storedCredential: StoredDeviceTrustCredential) throws {
|
||||
let data: Data
|
||||
do {
|
||||
let encoder = PropertyListEncoder()
|
||||
encoder.outputFormat = .binary
|
||||
data = try encoder.encode(storedCredential)
|
||||
} catch {
|
||||
throw DeviceTrustCredentialError.keyCreationFailed
|
||||
}
|
||||
|
||||
var attributes = baseQuery
|
||||
// Match the Secure Enclave key's lifetime: local to this device and invalid once its passcode is removed.
|
||||
attributes[kSecAttrAccessible] = kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly
|
||||
attributes[kSecAttrSynchronizable] = false
|
||||
attributes[kSecValueData] = data
|
||||
|
||||
let status = SecItemAdd(attributes as CFDictionary, nil)
|
||||
guard status == errSecSuccess else {
|
||||
logger.error("Could not store Device Trust credential in Keychain: \(status)")
|
||||
throw DeviceTrustCredentialError.keychain(status: status)
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
/// Deletes this Keychain record so debug-only callers can repeat credential creation.
|
||||
func delete() throws {
|
||||
let status = SecItemDelete(baseQuery as CFDictionary)
|
||||
switch status {
|
||||
case errSecSuccess, errSecItemNotFound:
|
||||
return
|
||||
|
||||
default:
|
||||
logger.error("Could not delete Device Trust credential from Keychain: \(status)")
|
||||
throw DeviceTrustCredentialError.keychain(status: status)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
private func decode(_ data: Data) throws -> StoredDeviceTrustCredential {
|
||||
do {
|
||||
let storedCredential = try PropertyListDecoder().decode(StoredDeviceTrustCredential.self, from: data)
|
||||
guard
|
||||
storedCredential.version == StoredDeviceTrustCredential.currentVersion,
|
||||
UUID(uuidString: storedCredential.id) != nil,
|
||||
!storedCredential.privateKeyRepresentation.isEmpty
|
||||
else {
|
||||
throw DeviceTrustCredentialError.invalidStoredCredential
|
||||
}
|
||||
|
||||
return storedCredential
|
||||
} catch let error as DeviceTrustCredentialError {
|
||||
throw error
|
||||
} catch {
|
||||
logger.error("Could not decode the stored Device Trust credential")
|
||||
throw DeviceTrustCredentialError.invalidStoredCredential
|
||||
}
|
||||
}
|
||||
|
||||
/// The selected service/account pair identifies exactly one Device Trust credential.
|
||||
///
|
||||
/// No access group is specified, so Security.framework uses the Verify app's default signed access group.
|
||||
private var baseQuery: [CFString: Any] {
|
||||
[
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrService: location.service,
|
||||
kSecAttrAccount: location.account,
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
// Teleport
|
||||
// Copyright (C) 2026 Gravitational, Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see http://www.gnu.org/licenses/
|
||||
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import LocalAuthentication
|
||||
import OSLog
|
||||
import Security
|
||||
|
||||
/// Implements Device Trust using two pieces of device-local storage.
|
||||
///
|
||||
/// The Secure Enclave owns the actual private key. Keychain stores only CryptoKit's encrypted representation of that
|
||||
/// key together with Teleport's opaque credential ID. The representation cannot be used to recover the private-key
|
||||
/// bytes and only the Secure Enclave that created it can restore the key.
|
||||
final class SecureEnclaveCredentialStore: Sendable {
|
||||
private let keychain: DeviceTrustCredentialKeychain
|
||||
private let logger = Logger.forType(SecureEnclaveCredentialStore.self)
|
||||
|
||||
init(keychain: DeviceTrustCredentialKeychain) {
|
||||
self.keychain = keychain
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Credential Operations
|
||||
|
||||
extension SecureEnclaveCredentialStore {
|
||||
/// Loads the configured location's existing credential or creates one when no record exists.
|
||||
///
|
||||
/// A corrupt record or unexpected Keychain error is never treated as absence. Silently replacing a credential in
|
||||
/// those cases would leave Teleport enrolled with a public key that the app can no longer use.
|
||||
func loadOrCreate() throws -> DeviceTrustCredential {
|
||||
try requireSecureEnclave()
|
||||
|
||||
do {
|
||||
let credential = try loadCredentialFromStorage()
|
||||
logger.debug("Loaded existing Device Trust credential")
|
||||
return credential
|
||||
} catch DeviceTrustCredentialError.notFound {
|
||||
// Absence is the only condition under which enrollment may create a credential.
|
||||
}
|
||||
|
||||
let signingKey = try createSigningKey()
|
||||
let storedCredential = StoredDeviceTrustCredential(
|
||||
id: UUID().uuidString.lowercased(),
|
||||
privateKeyRepresentation: signingKey.dataRepresentation,
|
||||
)
|
||||
|
||||
do {
|
||||
// This is intentionally an add, not an update: an existing identity must never be overwritten.
|
||||
try keychain.insert(storedCredential)
|
||||
} catch DeviceTrustCredentialError.keychain(status: errSecDuplicateItem) {
|
||||
// Another creator won the race. Discard this new handle and use the credential that became canonical.
|
||||
logger.debug("Another caller stored a Device Trust credential; loading it")
|
||||
return try loadCredentialFromStorage()
|
||||
}
|
||||
|
||||
logger.debug("Created Device Trust credential")
|
||||
return makePublicCredential(from: storedCredential, signingKey: signingKey)
|
||||
}
|
||||
|
||||
/// Loads an existing credential for authentication without creating or rotating key material.
|
||||
func loadExistingCredential() throws -> DeviceTrustCredential {
|
||||
try requireSecureEnclave()
|
||||
return try loadCredentialFromStorage()
|
||||
}
|
||||
|
||||
/// Uses the existing private key to sign a raw Teleport challenge after confirming user presence.
|
||||
func signChallenge(
|
||||
_ challenge: Data,
|
||||
purpose: DeviceTrustChallengePurpose,
|
||||
) async throws -> Data {
|
||||
guard !challenge.isEmpty else {
|
||||
throw DeviceTrustCredentialError.emptyChallenge
|
||||
}
|
||||
|
||||
try requireSecureEnclave()
|
||||
|
||||
// Load the record before prompting so a missing or inaccessible credential remains a Keychain error.
|
||||
let storedCredential = try keychain.load()
|
||||
let context = LAContext()
|
||||
context.localizedReason = purpose.signingPromptReason
|
||||
let signingKey = try restoreSigningKey(
|
||||
from: storedCredential,
|
||||
authenticationContext: context,
|
||||
)
|
||||
|
||||
try await authorizeSigningWithUserPresence(using: context, for: purpose)
|
||||
defer { context.invalidate() }
|
||||
|
||||
do {
|
||||
// The Data overload hashes the challenge with SHA-256 once. DER is the format Teleport's Go verifier
|
||||
// expects.
|
||||
return try signingKey.signature(for: challenge).derRepresentation
|
||||
} catch {
|
||||
logger.error("Could not sign the Device Trust challenge")
|
||||
throw DeviceTrustCredentialError.signingFailed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Credential Loading and Creation
|
||||
|
||||
extension SecureEnclaveCredentialStore {
|
||||
private func requireSecureEnclave() throws {
|
||||
guard SecureEnclave.isAvailable else {
|
||||
throw DeviceTrustCredentialError.secureEnclaveUnavailable
|
||||
}
|
||||
}
|
||||
|
||||
private func loadCredentialFromStorage() throws -> DeviceTrustCredential {
|
||||
let storedCredential = try keychain.load()
|
||||
let signingKey = try restoreSigningKey(from: storedCredential)
|
||||
return makePublicCredential(from: storedCredential, signingKey: signingKey)
|
||||
}
|
||||
|
||||
/// Creates a P-256 signing key whose private operations require local user presence.
|
||||
private func createSigningKey() throws -> SecureEnclave.P256.Signing.PrivateKey {
|
||||
let accessControl = try makeSigningKeyAccessControl()
|
||||
|
||||
do {
|
||||
return try SecureEnclave.P256.Signing.PrivateKey(accessControl: accessControl)
|
||||
} catch {
|
||||
logger.error("Could not create a Secure Enclave Device Trust key")
|
||||
throw DeviceTrustCredentialError.keyCreationFailed
|
||||
}
|
||||
}
|
||||
|
||||
/// Restores CryptoKit's handle to the existing Secure Enclave key.
|
||||
///
|
||||
/// Restoring a handle is not the same as exporting the private key. If restoration fails, the record remains in
|
||||
/// place so callers can require deliberate re-enrollment rather than silently changing device identity.
|
||||
private func restoreSigningKey(
|
||||
from storedCredential: StoredDeviceTrustCredential,
|
||||
authenticationContext: LAContext? = nil,
|
||||
) throws -> SecureEnclave.P256.Signing.PrivateKey {
|
||||
do {
|
||||
return try SecureEnclave.P256.Signing.PrivateKey(
|
||||
dataRepresentation: storedCredential.privateKeyRepresentation,
|
||||
authenticationContext: authenticationContext,
|
||||
)
|
||||
} catch {
|
||||
logger.error("Could not restore the stored Device Trust credential")
|
||||
throw DeviceTrustCredentialError.invalidStoredCredential
|
||||
}
|
||||
}
|
||||
|
||||
private func makePublicCredential(
|
||||
from storedCredential: StoredDeviceTrustCredential,
|
||||
signingKey: SecureEnclave.P256.Signing.PrivateKey,
|
||||
) -> DeviceTrustCredential {
|
||||
DeviceTrustCredential(
|
||||
id: storedCredential.id,
|
||||
publicKeyDER: signingKey.publicKey.derRepresentation,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Signing Authorization
|
||||
|
||||
extension SecureEnclaveCredentialStore {
|
||||
/// Evaluates the key's user-presence policy through LocalAuthentication before asking CryptoKit to sign.
|
||||
///
|
||||
/// `LAContext` reports cancellation as a direct `LAError`, so callers can ignore it without relying on the
|
||||
/// undocumented error wrappers produced by CryptoKit. The same authorized context is reused for signing.
|
||||
private func authorizeSigningWithUserPresence(
|
||||
using context: LAContext,
|
||||
for purpose: DeviceTrustChallengePurpose,
|
||||
) async throws {
|
||||
let accessControl = try makeSigningKeyAccessControl()
|
||||
|
||||
let authorized: Bool
|
||||
do {
|
||||
authorized = try await context.evaluateAccessControl(
|
||||
accessControl,
|
||||
operation: .useKeySign,
|
||||
localizedReason: purpose.signingPromptReason,
|
||||
)
|
||||
} catch let error as LAError {
|
||||
switch error.code {
|
||||
case .appCancel, .systemCancel, .userCancel:
|
||||
throw DeviceTrustCredentialError.signingAuthorizationCancelled
|
||||
|
||||
default:
|
||||
throw DeviceTrustCredentialError.signingAuthorizationFailed
|
||||
}
|
||||
} catch is CancellationError {
|
||||
throw DeviceTrustCredentialError.signingAuthorizationCancelled
|
||||
} catch {
|
||||
throw DeviceTrustCredentialError.signingAuthorizationFailed
|
||||
}
|
||||
|
||||
guard authorized else {
|
||||
throw DeviceTrustCredentialError.signingAuthorizationFailed
|
||||
}
|
||||
|
||||
// Only the explicit LocalAuthentication call may present UI. CryptoKit must reuse this authorization or fail.
|
||||
context.interactionNotAllowed = true
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Key Access Policy
|
||||
|
||||
extension SecureEnclaveCredentialStore {
|
||||
/// Builds the policy enforced by Security.framework whenever the private key signs.
|
||||
///
|
||||
/// - `WhenPasscodeSetThisDeviceOnly` prevents migration and invalidates the credential if the passcode is removed.
|
||||
/// - `privateKeyUsage` applies the policy to private-key operations rather than key creation or public-key reads.
|
||||
/// - `userPresence` accepts Face ID, Touch ID, or the device passcode.
|
||||
private func makeSigningKeyAccessControl() throws -> SecAccessControl {
|
||||
var error: Unmanaged<CFError>? = nil
|
||||
guard
|
||||
let accessControl = SecAccessControlCreateWithFlags(
|
||||
kCFAllocatorDefault,
|
||||
kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
|
||||
[.privateKeyUsage, .userPresence],
|
||||
&error,
|
||||
)
|
||||
else {
|
||||
if let error {
|
||||
logger.error("Could not create the Device Trust access-control policy: \(error.takeRetainedValue())")
|
||||
}
|
||||
|
||||
throw DeviceTrustCredentialError.accessControlCreationFailed
|
||||
}
|
||||
|
||||
return accessControl
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Signing Prompt Copy
|
||||
|
||||
extension DeviceTrustChallengePurpose {
|
||||
fileprivate var signingPromptReason: String {
|
||||
switch self {
|
||||
case .enrollment:
|
||||
"Confirm your presence to enroll this device with Teleport."
|
||||
|
||||
case .authentication:
|
||||
"Confirm your presence to verify this device with Teleport."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,16 +17,35 @@
|
||||
#if DEBUG
|
||||
|
||||
import SwiftUI
|
||||
import SwiftUINavigation
|
||||
|
||||
/// DebugView serves as the entrypoint for in-development ideas, UI component catalogs, and any other bits that we
|
||||
/// want to be able to see when testing on a device, but which we don't want to ship to customers.
|
||||
///
|
||||
/// - Note: DebugView and all its associated components should always be wrapped in `#if DEBUG` checks.
|
||||
struct DebugView: View {
|
||||
@Bindable
|
||||
var viewModel: DebugViewModel
|
||||
|
||||
var body: some View {
|
||||
Text("Placeholder for DebugView")
|
||||
NavigationStack {
|
||||
List {
|
||||
Group {
|
||||
Section("Feature Demos") {
|
||||
Button("Secure Enclave Storage", systemImage: "lock") {
|
||||
viewModel.destination = .deviceTrustCredentialDemo(.init())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Debug")
|
||||
|
||||
// MARK: - Navigation
|
||||
|
||||
.navigationDestination(item: $viewModel.destination.deviceTrustCredentialDemo) { viewModel in
|
||||
FeatureDemo.DeviceTrustCredentialView(viewModel: viewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,9 +17,19 @@
|
||||
#if DEBUG
|
||||
|
||||
import Observation
|
||||
import SwiftNavigation
|
||||
|
||||
@Observable
|
||||
final class DebugViewModel {}
|
||||
final class DebugViewModel {
|
||||
@CasePathable
|
||||
enum Destination {
|
||||
// MARK: - Feature Demos
|
||||
|
||||
case deviceTrustCredentialDemo(FeatureDemo.DeviceTrustCredentialViewModel)
|
||||
}
|
||||
|
||||
var destination: Destination? = nil
|
||||
}
|
||||
|
||||
// MARK: - SheetPresentable
|
||||
|
||||
|
||||
+262
@@ -0,0 +1,262 @@
|
||||
// Teleport
|
||||
// Copyright (C) 2026 Gravitational, Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see http://www.gnu.org/licenses/
|
||||
|
||||
#if DEBUG
|
||||
|
||||
import SwiftUI
|
||||
|
||||
extension FeatureDemo {
|
||||
struct DeviceTrustCredentialView: View {
|
||||
@Bindable
|
||||
var viewModel: DeviceTrustCredentialViewModel
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
descriptionSection
|
||||
deviceSection
|
||||
actionsSection
|
||||
statusSection
|
||||
storedCredentialSection
|
||||
challengeAndSignatureSection
|
||||
}
|
||||
.navigationTitle("Device Trust Credential")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sections
|
||||
|
||||
extension FeatureDemo.DeviceTrustCredentialView {
|
||||
var descriptionSection: some View {
|
||||
Section {
|
||||
Text(
|
||||
"""
|
||||
Creates or loads a separate demo credential, reloads it from Keychain, requests user presence \
|
||||
to authorize signing, signs a random challenge, and verifies the signature locally.
|
||||
""",
|
||||
)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
var deviceSection: some View {
|
||||
Section("Device") {
|
||||
HStack {
|
||||
Text("Secure Enclave")
|
||||
Label(
|
||||
viewModel.secureEnclaveAvailable
|
||||
? "Available"
|
||||
: "Unavailable",
|
||||
systemImage: viewModel.secureEnclaveAvailable
|
||||
? "checkmark.circle.fill"
|
||||
: "xmark.circle.fill",
|
||||
)
|
||||
.labelIconToTitleSpacing(.small)
|
||||
.foregroundStyle(viewModel.secureEnclaveAvailable ? Color.success : Color.danger)
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var actionsSection: some View {
|
||||
Section {
|
||||
runRoundTripButton
|
||||
loadCredentialButton
|
||||
resetCredentialButton
|
||||
} header: {
|
||||
Text("Actions")
|
||||
} footer: {
|
||||
Text(
|
||||
"This demo uses a separate Keychain item and never reads or modifies the app's Device Trust credential.",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
var statusSection: some View {
|
||||
Section("Status") {
|
||||
statusView
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var storedCredentialSection: some View {
|
||||
if let credentialID = viewModel.credentialID {
|
||||
Section("Stored Demo Credential") {
|
||||
detail("Credential ID", value: credentialID)
|
||||
|
||||
if let fingerprint = viewModel.publicKeyFingerprint {
|
||||
detail("Public Key Fingerprint (SHA-256)", value: fingerprint)
|
||||
}
|
||||
|
||||
if let publicKey = viewModel.publicKeyDERBase64 {
|
||||
detail("Public Key DER (Base64)", value: publicKey)
|
||||
}
|
||||
|
||||
if let matched = viewModel.credentialReloadMatched {
|
||||
result("Credential persisted correctly", succeeded: matched)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var challengeAndSignatureSection: some View {
|
||||
if let challenge = viewModel.challengeHex {
|
||||
Section("Challenge and Signature") {
|
||||
detail("Random 32-byte challenge", value: challenge)
|
||||
|
||||
if let signature = viewModel.signatureDERBase64 {
|
||||
detail("ECDSA Signature (DER, Base64-encoded)", value: signature)
|
||||
}
|
||||
|
||||
if let verified = viewModel.signatureVerified {
|
||||
result("Signature verified with reloaded public key", succeeded: verified)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
extension FeatureDemo.DeviceTrustCredentialView {
|
||||
var runRoundTripButton: some View {
|
||||
Button {
|
||||
Task { await viewModel.runRoundTrip() }
|
||||
} label: {
|
||||
Label("Create, Sign, and Verify", systemImage: "checkmark.shield")
|
||||
}
|
||||
.disabled(viewModel.isRunning || !viewModel.secureEnclaveAvailable)
|
||||
}
|
||||
|
||||
var loadCredentialButton: some View {
|
||||
Button {
|
||||
viewModel.loadExistingCredential()
|
||||
} label: {
|
||||
Label("Load Demo Credential", systemImage: "key.viewfinder")
|
||||
}
|
||||
.disabled(viewModel.isRunning || !viewModel.secureEnclaveAvailable)
|
||||
}
|
||||
|
||||
var resetCredentialButton: some View {
|
||||
Button {
|
||||
viewModel.resetDemoCredential()
|
||||
} label: {
|
||||
Label {
|
||||
Text("Delete Demo Credential")
|
||||
} icon: {
|
||||
Image(systemName: "trash")
|
||||
.foregroundStyle(.danger)
|
||||
}
|
||||
.tint(.danger)
|
||||
}
|
||||
.disabled(viewModel.isRunning)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Status
|
||||
|
||||
extension FeatureDemo.DeviceTrustCredentialView {
|
||||
@ViewBuilder
|
||||
var statusView: some View {
|
||||
switch viewModel.status {
|
||||
case .idle:
|
||||
status(
|
||||
"Ready",
|
||||
message: "Create, sign, and verify on a physical device.",
|
||||
systemImage: "circle.dotted",
|
||||
color: .secondary,
|
||||
)
|
||||
|
||||
case let .loading(message):
|
||||
HStack(spacing: 12) {
|
||||
ProgressView()
|
||||
Text(message)
|
||||
}
|
||||
|
||||
case let .success(message):
|
||||
status(
|
||||
"Success",
|
||||
message: message,
|
||||
systemImage: "checkmark.circle.fill",
|
||||
color: .success,
|
||||
)
|
||||
|
||||
case .cancelled:
|
||||
status(
|
||||
"Cancelled",
|
||||
message: "No signature was produced.",
|
||||
systemImage: "xmark.circle",
|
||||
color: .secondary,
|
||||
)
|
||||
|
||||
case let .failure(message):
|
||||
status(
|
||||
"Failed",
|
||||
message: message,
|
||||
systemImage: "exclamationmark.triangle.fill",
|
||||
color: .danger,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func status(
|
||||
_ title: String,
|
||||
message: String,
|
||||
systemImage: String,
|
||||
color: Color,
|
||||
) -> some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: systemImage)
|
||||
.foregroundStyle(color)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title)
|
||||
.fontWeight(.semibold)
|
||||
Text(message)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Result Rows
|
||||
|
||||
extension FeatureDemo.DeviceTrustCredentialView {
|
||||
private func detail(_ title: String, value: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(value)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
|
||||
private func result(_ title: String, succeeded: Bool) -> some View {
|
||||
Label(
|
||||
title,
|
||||
systemImage: succeeded ? "checkmark.circle.fill" : "xmark.circle.fill",
|
||||
)
|
||||
.foregroundStyle(succeeded ? Color.success : Color.danger)
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
+278
@@ -0,0 +1,278 @@
|
||||
// Teleport
|
||||
// Copyright (C) 2026 Gravitational, Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see http://www.gnu.org/licenses/
|
||||
|
||||
#if DEBUG
|
||||
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import Observation
|
||||
import Security
|
||||
|
||||
extension FeatureDemo {
|
||||
@Observable @MainActor
|
||||
final class DeviceTrustCredentialViewModel {
|
||||
private(set) var status: DeviceTrustCredentialDemoStatus = .idle
|
||||
private(set) var credentialID: String? = nil
|
||||
private(set) var publicKeyFingerprint: String? = nil
|
||||
private(set) var publicKeyDERBase64: String? = nil
|
||||
private(set) var credentialReloadMatched: Bool? = nil
|
||||
private(set) var challengeHex: String? = nil
|
||||
private(set) var signatureDERBase64: String? = nil
|
||||
private(set) var signatureVerified: Bool? = nil
|
||||
|
||||
@ObservationIgnored
|
||||
private let credentialClient: DeviceTrustCredentialClient
|
||||
|
||||
init() {
|
||||
self.credentialClient = DeviceTrustCredentialClient.value(location: .demo)
|
||||
}
|
||||
|
||||
var secureEnclaveAvailable: Bool {
|
||||
SecureEnclave.isAvailable
|
||||
}
|
||||
|
||||
var isRunning: Bool {
|
||||
if case .loading = status {
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - User Actions
|
||||
|
||||
extension FeatureDemo.DeviceTrustCredentialViewModel {
|
||||
/// Exercises persistence, Secure Enclave signing, and verification using the public key loaded from storage.
|
||||
func runRoundTrip() async {
|
||||
guard !isRunning else {
|
||||
return
|
||||
}
|
||||
|
||||
resetResults()
|
||||
|
||||
do {
|
||||
status = .loading("Creating or loading the demo credential…")
|
||||
let initialCredential = try credentialClient.loadOrCreate()
|
||||
show(initialCredential)
|
||||
|
||||
status = .loading("Loading the credential back from Keychain…")
|
||||
let reloadedCredential = try credentialClient.load()
|
||||
credentialReloadMatched = initialCredential == reloadedCredential
|
||||
|
||||
guard credentialReloadMatched == true else {
|
||||
throw DeviceTrustCredentialDemoError.credentialChangedDuringReload
|
||||
}
|
||||
|
||||
let challenge = Self.makeRandomChallenge()
|
||||
challengeHex = Self.hex(challenge)
|
||||
|
||||
status = .loading("Requesting user presence to authorize signing…")
|
||||
let signature = try await credentialClient.signChallenge(challenge, .authentication)
|
||||
signatureDERBase64 = signature.base64EncodedString()
|
||||
|
||||
status = .loading("Verifying the signature with the reloaded public key…")
|
||||
let verified = try Self.verify(
|
||||
signature: signature,
|
||||
for: challenge,
|
||||
using: reloadedCredential.publicKeyDER,
|
||||
)
|
||||
signatureVerified = verified
|
||||
|
||||
guard verified else {
|
||||
throw DeviceTrustCredentialDemoError.signatureRejected
|
||||
}
|
||||
|
||||
status = .success(
|
||||
"The Secure Enclave key signed the challenge, and the reloaded public key verified the signature.",
|
||||
)
|
||||
} catch DeviceTrustCredentialError.signingAuthorizationCancelled {
|
||||
// Cancellation is expected control flow. Nothing is sent and no failure is shown.
|
||||
status = .cancelled
|
||||
} catch {
|
||||
status = .failure(Self.message(for: error))
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads the existing demo credential without creating one or showing a user-presence prompt.
|
||||
func loadExistingCredential() {
|
||||
guard !isRunning else {
|
||||
return
|
||||
}
|
||||
|
||||
resetResults()
|
||||
status = .loading("Loading the existing demo credential from Keychain…")
|
||||
|
||||
do {
|
||||
let credential = try credentialClient.load()
|
||||
show(credential)
|
||||
status = .success("Loaded the demo credential without requesting signing authorization.")
|
||||
} catch {
|
||||
status = .failure(Self.message(for: error))
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes only the debug demo's Keychain record, bypassing the production credential client.
|
||||
func resetDemoCredential() {
|
||||
guard !isRunning else {
|
||||
return
|
||||
}
|
||||
|
||||
resetResults()
|
||||
status = .loading("Deleting the demo credential from Keychain…")
|
||||
|
||||
do {
|
||||
// Keep the storage location explicit here so this debug escape hatch cannot affect the app credential.
|
||||
try DeviceTrustCredentialKeychain(location: .demo).delete()
|
||||
status = .success("Deleted the demo credential. The app's Device Trust credential was not touched.")
|
||||
} catch {
|
||||
status = .failure(Self.message(for: error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Presented Results
|
||||
|
||||
extension FeatureDemo.DeviceTrustCredentialViewModel {
|
||||
private func resetResults() {
|
||||
credentialID = nil
|
||||
publicKeyFingerprint = nil
|
||||
publicKeyDERBase64 = nil
|
||||
credentialReloadMatched = nil
|
||||
challengeHex = nil
|
||||
signatureDERBase64 = nil
|
||||
signatureVerified = nil
|
||||
}
|
||||
|
||||
private func show(_ credential: DeviceTrustCredential) {
|
||||
credentialID = credential.id
|
||||
publicKeyFingerprint = Self.hex(
|
||||
Data(SHA256.hash(data: credential.publicKeyDER)),
|
||||
separator: ":",
|
||||
)
|
||||
publicKeyDERBase64 = credential.publicKeyDER.base64EncodedString()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Challenge and Signature
|
||||
|
||||
extension FeatureDemo.DeviceTrustCredentialViewModel {
|
||||
private static func makeRandomChallenge() -> Data {
|
||||
var generator = SystemRandomNumberGenerator()
|
||||
return Data(
|
||||
(0 ..< 32).map { _ in
|
||||
UInt8.random(in: UInt8.min ... UInt8.max, using: &generator)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private static func verify(
|
||||
signature: Data,
|
||||
for challenge: Data,
|
||||
using publicKeyDER: Data,
|
||||
) throws -> Bool {
|
||||
let publicKey = try P256.Signing.PublicKey(derRepresentation: publicKeyDER)
|
||||
let signature = try P256.Signing.ECDSASignature(derRepresentation: signature)
|
||||
return publicKey.isValidSignature(signature, for: challenge)
|
||||
}
|
||||
|
||||
private static func hex(_ data: Data, separator: String = "") -> String {
|
||||
data.map { String(format: "%02X", $0) }.joined(separator: separator)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Error Presentation
|
||||
|
||||
extension FeatureDemo.DeviceTrustCredentialViewModel {
|
||||
private static func message(for error: any Error) -> String {
|
||||
guard let error = error as? DeviceTrustCredentialError else {
|
||||
return error.localizedDescription
|
||||
}
|
||||
|
||||
switch error {
|
||||
case .secureEnclaveUnavailable:
|
||||
return "The Secure Enclave is unavailable. Run this demo on a supported physical device."
|
||||
|
||||
case .notFound:
|
||||
return "No demo credential exists yet. Create, sign, and verify to create one."
|
||||
|
||||
case .invalidStoredCredential:
|
||||
return "Keychain contains a credential that this Secure Enclave cannot restore."
|
||||
|
||||
case .emptyChallenge:
|
||||
return "The signing API received an empty challenge."
|
||||
|
||||
case .keyCreationFailed:
|
||||
return "CryptoKit could not create the Secure Enclave signing key."
|
||||
|
||||
case .accessControlCreationFailed:
|
||||
return "Security.framework could not create the key's access-control policy."
|
||||
|
||||
case .signingAuthorizationCancelled, .signingAuthorizationFailed, .signingFailed:
|
||||
return operationMessage(for: error)
|
||||
|
||||
case let .keychain(status):
|
||||
let detail = SecCopyErrorMessageString(status, nil) as String? ?? "Unknown Keychain error"
|
||||
return "Keychain returned OSStatus \(status): \(detail)"
|
||||
}
|
||||
}
|
||||
|
||||
private static func operationMessage(for error: DeviceTrustCredentialError) -> String {
|
||||
switch error {
|
||||
case .signingAuthorizationCancelled:
|
||||
"Signing authorization was cancelled."
|
||||
|
||||
case .signingAuthorizationFailed:
|
||||
"LocalAuthentication could not confirm user presence."
|
||||
|
||||
case .signingFailed:
|
||||
"CryptoKit could not sign with the stored Secure Enclave key."
|
||||
|
||||
default:
|
||||
preconditionFailure("Only signing authorization and signing errors belong here")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Supporting Types
|
||||
|
||||
extension FeatureDemo.DeviceTrustCredentialViewModel {
|
||||
enum DeviceTrustCredentialDemoStatus {
|
||||
case idle
|
||||
case loading(String)
|
||||
case success(String)
|
||||
case cancelled
|
||||
case failure(String)
|
||||
}
|
||||
|
||||
private enum DeviceTrustCredentialDemoError: LocalizedError {
|
||||
case credentialChangedDuringReload
|
||||
case signatureRejected
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .credentialChangedDuringReload:
|
||||
"The credential loaded from Keychain did not match the credential returned by loadOrCreate."
|
||||
|
||||
case .signatureRejected:
|
||||
"CryptoKit rejected the signature produced by the Secure Enclave."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,24 @@
|
||||
// Teleport
|
||||
// Copyright (C) 2026 Gravitational, Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see http://www.gnu.org/licenses/
|
||||
|
||||
#if DEBUG
|
||||
|
||||
import Foundation
|
||||
|
||||
/// An namespace for all feature demos we'd like to include inside of `DebugView`.
|
||||
enum FeatureDemo {}
|
||||
|
||||
#endif
|
||||
@@ -4,6 +4,8 @@
|
||||
<dict>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Teleport Verify uses the built-in QR code scanner to receive enrollment data from the Teleport Web UI</string>
|
||||
<key>NSFaceIDUsageDescription</key>
|
||||
<string>Teleport Verify uses Face ID to confirm your presence when proving this device's identity</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
|
||||
Reference in New Issue
Block a user