From a1b7a53be011748162ba99eaac7feb3c3a23cd2d Mon Sep 17 00:00:00 2001 From: Gustavo Medori Date: Wed, 29 Jul 2026 11:25:04 -0600 Subject: [PATCH] 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 --- .../Verify/App Lifecycle/Dependencies.swift | 3 + .../DeviceTrustCredentialClient.swift | 119 ++++++++ .../DeviceTrustCredentialKeychain.swift | 174 +++++++++++ .../SecureEnclaveCredentialStore.swift | 255 ++++++++++++++++ .../Verify/Features/Debugging/DebugView.swift | 21 +- .../Features/Debugging/DebugViewModel.swift | 12 +- .../DeviceTrustCredentialView.swift | 262 +++++++++++++++++ .../DeviceTrustCredentialViewModel.swift | 278 ++++++++++++++++++ .../Debugging/Feature Demos/FeatureDemo.swift | 24 ++ mobile/Verify/Verify/Info.plist | 2 + 10 files changed, 1148 insertions(+), 2 deletions(-) create mode 100644 mobile/Verify/Verify/Device Trust/Credentials/DeviceTrustCredentialClient.swift create mode 100644 mobile/Verify/Verify/Device Trust/Credentials/DeviceTrustCredentialKeychain.swift create mode 100644 mobile/Verify/Verify/Device Trust/Credentials/SecureEnclaveCredentialStore.swift create mode 100644 mobile/Verify/Verify/Features/Debugging/Feature Demos/Device Trust Credential Demo/DeviceTrustCredentialView.swift create mode 100644 mobile/Verify/Verify/Features/Debugging/Feature Demos/Device Trust Credential Demo/DeviceTrustCredentialViewModel.swift create mode 100644 mobile/Verify/Verify/Features/Debugging/Feature Demos/FeatureDemo.swift diff --git a/mobile/Verify/Verify/App Lifecycle/Dependencies.swift b/mobile/Verify/Verify/App Lifecycle/Dependencies.swift index 159214dd731..4070c67f78a 100644 --- a/mobile/Verify/Verify/App Lifecycle/Dependencies.swift +++ b/mobile/Verify/Verify/App Lifecycle/Dependencies.swift @@ -21,4 +21,7 @@ import DependenciesMacros extension DependencyValues { @DependencyEntry(liveValue: EnrollClient.liveValue) nonisolated var enrollClient = EnrollClient() + + @DependencyEntry(liveValue: DeviceTrustCredentialClient.liveValue) + nonisolated var deviceTrustCredentialClient = DeviceTrustCredentialClient() } diff --git a/mobile/Verify/Verify/Device Trust/Credentials/DeviceTrustCredentialClient.swift b/mobile/Verify/Verify/Device Trust/Credentials/DeviceTrustCredentialClient.swift new file mode 100644 index 00000000000..28a13654d5c --- /dev/null +++ b/mobile/Verify/Verify/Device Trust/Credentials/DeviceTrustCredentialClient.swift @@ -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) +} diff --git a/mobile/Verify/Verify/Device Trust/Credentials/DeviceTrustCredentialKeychain.swift b/mobile/Verify/Verify/Device Trust/Credentials/DeviceTrustCredentialKeychain.swift new file mode 100644 index 00000000000..498501a51f8 --- /dev/null +++ b/mobile/Verify/Verify/Device Trust/Credentials/DeviceTrustCredentialKeychain.swift @@ -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, + ] + } +} diff --git a/mobile/Verify/Verify/Device Trust/Credentials/SecureEnclaveCredentialStore.swift b/mobile/Verify/Verify/Device Trust/Credentials/SecureEnclaveCredentialStore.swift new file mode 100644 index 00000000000..542dc66312f --- /dev/null +++ b/mobile/Verify/Verify/Device Trust/Credentials/SecureEnclaveCredentialStore.swift @@ -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? = 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." + } + } +} diff --git a/mobile/Verify/Verify/Features/Debugging/DebugView.swift b/mobile/Verify/Verify/Features/Debugging/DebugView.swift index 2f2998a5560..43ca8dc04cb 100644 --- a/mobile/Verify/Verify/Features/Debugging/DebugView.swift +++ b/mobile/Verify/Verify/Features/Debugging/DebugView.swift @@ -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) + } + } } } diff --git a/mobile/Verify/Verify/Features/Debugging/DebugViewModel.swift b/mobile/Verify/Verify/Features/Debugging/DebugViewModel.swift index a4012f6bf32..03f59943bdb 100644 --- a/mobile/Verify/Verify/Features/Debugging/DebugViewModel.swift +++ b/mobile/Verify/Verify/Features/Debugging/DebugViewModel.swift @@ -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 diff --git a/mobile/Verify/Verify/Features/Debugging/Feature Demos/Device Trust Credential Demo/DeviceTrustCredentialView.swift b/mobile/Verify/Verify/Features/Debugging/Feature Demos/Device Trust Credential Demo/DeviceTrustCredentialView.swift new file mode 100644 index 00000000000..dde37364b32 --- /dev/null +++ b/mobile/Verify/Verify/Features/Debugging/Feature Demos/Device Trust Credential Demo/DeviceTrustCredentialView.swift @@ -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 diff --git a/mobile/Verify/Verify/Features/Debugging/Feature Demos/Device Trust Credential Demo/DeviceTrustCredentialViewModel.swift b/mobile/Verify/Verify/Features/Debugging/Feature Demos/Device Trust Credential Demo/DeviceTrustCredentialViewModel.swift new file mode 100644 index 00000000000..c5d520b15a4 --- /dev/null +++ b/mobile/Verify/Verify/Features/Debugging/Feature Demos/Device Trust Credential Demo/DeviceTrustCredentialViewModel.swift @@ -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 diff --git a/mobile/Verify/Verify/Features/Debugging/Feature Demos/FeatureDemo.swift b/mobile/Verify/Verify/Features/Debugging/Feature Demos/FeatureDemo.swift new file mode 100644 index 00000000000..ca611c3365a --- /dev/null +++ b/mobile/Verify/Verify/Features/Debugging/Feature Demos/FeatureDemo.swift @@ -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 diff --git a/mobile/Verify/Verify/Info.plist b/mobile/Verify/Verify/Info.plist index 4bbd38d5abe..9e0f895cb28 100644 --- a/mobile/Verify/Verify/Info.plist +++ b/mobile/Verify/Verify/Info.plist @@ -4,6 +4,8 @@ NSCameraUsageDescription Teleport Verify uses the built-in QR code scanner to receive enrollment data from the Teleport Web UI + NSFaceIDUsageDescription + Teleport Verify uses Face ID to confirm your presence when proving this device's identity CFBundleURLTypes