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:
Gustavo Medori
2026-07-29 17:25:04 +00:00
committed by GitHub
parent 5dacf73a10
commit a1b7a53be0
10 changed files with 1148 additions and 2 deletions
@@ -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
@@ -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
@@ -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
+2
View File
@@ -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>