Integrate live phone and watch remote prototype with guarded motion control

This commit is contained in:
2026-09-18 19:11:52 +04:00
parent 3e787543c0
commit 8131f4f873
54 changed files with 4355 additions and 329 deletions
@@ -0,0 +1,259 @@
import Foundation
import Security
import TVTransport
/// The smallest Security-framework seam needed by the repository. Tests inject
/// this protocol; the production implementation below is the only type that
/// calls Security directly.
public protocol KeychainClient: Sendable {
func add(service: String, account: String, data: Data, accessibility: KeychainAccessibility) throws
func read(service: String, account: String) throws -> Data
func update(service: String, account: String, data: Data) throws
func delete(service: String, account: String) throws
}
public enum KeychainAccessibility: Sendable, Equatable {
/// Strongest default, but unavailable while the phone is locked.
case whenUnlockedThisDeviceOnly
/// Candidate for a phone-lock relay after the first device unlock.
/// It is still unavailable before the first unlock after reboot.
case afterFirstUnlockThisDeviceOnly
fileprivate var secValue: CFString {
switch self {
case .whenUnlockedThisDeviceOnly: return kSecAttrAccessibleWhenUnlockedThisDeviceOnly
case .afterFirstUnlockThisDeviceOnly: return kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
}
}
}
public enum KeychainError: Error, Sendable, Equatable {
case notFound
case locked
case unexpectedStatus(Int32)
}
public struct SystemKeychainClient: KeychainClient, Sendable {
public init() {}
public func add(service: String, account: String, data: Data, accessibility: KeychainAccessibility) throws {
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: account,
kSecValueData: data,
kSecAttrAccessible: accessibility.secValue
]
try check(SecItemAdd(query as CFDictionary, nil))
}
public func read(service: String, account: String) throws -> Data {
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: account,
kSecReturnData: true,
kSecMatchLimit: kSecMatchLimitOne
]
var result: CFTypeRef?
try check(SecItemCopyMatching(query as CFDictionary, &result))
guard let data = result as? Data else { throw KeychainError.unexpectedStatus(errSecInternalComponent) }
return data
}
public func update(service: String, account: String, data: Data) throws {
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: account
]
try check(SecItemUpdate(query as CFDictionary, [kSecValueData: data] as CFDictionary))
}
public func delete(service: String, account: String) throws {
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: account
]
try check(SecItemDelete(query as CFDictionary))
}
private func check(_ status: OSStatus) throws {
guard status != errSecSuccess else { return }
switch status {
case errSecItemNotFound: throw KeychainError.notFound
case errSecInteractionNotAllowed: throw KeychainError.locked
default: throw KeychainError.unexpectedStatus(status)
}
}
}
public enum PairingState: String, Codable, Sendable, Equatable {
case paired
case requiresReapproval
case revoked
}
public enum CertificateTrustValidation: Sendable, Equatable {
case notPinned
case accepted
case requiresReapproval
}
public struct TVSecurityRecord: Codable, Sendable, Equatable {
public let token: String?
public let certificateSHA256: Data?
public let pairingState: PairingState
public let trustApprovedAt: Date?
/// A fingerprint learned from the same unauthenticated first connection
/// does not authenticate the TV. Keep this limitation explicit in storage.
public let fingerprintSource: String
public init(
token: String? = nil,
certificateSHA256: Data? = nil,
pairingState: PairingState = .paired,
trustApprovedAt: Date? = nil,
fingerprintSource: String = "unauthenticated-first-use"
) {
self.token = token
self.certificateSHA256 = certificateSHA256
self.pairingState = pairingState
self.trustApprovedAt = trustApprovedAt
self.fingerprintSource = fingerprintSource
}
}
/// iPhone-only Keychain persistence keyed by the opaque television UUID.
/// Tokens and trust records never cross the WatchConnectivity boundary.
public actor KeychainTVAuthorizationStore: TVAuthorizationStore {
public static let service = "com.wristivo.tv-security"
private let client: any KeychainClient
private let accessibility: KeychainAccessibility
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
public init(
client: any KeychainClient = SystemKeychainClient(),
accessibility: KeychainAccessibility = .afterFirstUnlockThisDeviceOnly
) {
self.client = client
self.accessibility = accessibility
}
public func authorization(for televisionID: UUID) async throws -> StoredTVAuthorization {
guard let record = try optionalRecord(for: televisionID) else {
return StoredTVAuthorization(token: nil)
}
guard record.pairingState == .paired else {
return StoredTVAuthorization(token: nil, trust: trustPolicy(for: record))
}
return StoredTVAuthorization(token: record.token, trust: trustPolicy(for: record))
}
public func saveToken(_ token: String, for televisionID: UUID) async throws {
guard !token.isEmpty, token.utf8.count <= 4096 else { throw KeychainError.unexpectedStatus(errSecParam) }
var record = try optionalRecord(for: televisionID) ?? TVSecurityRecord()
record = TVSecurityRecord(
token: token,
certificateSHA256: record.certificateSHA256,
pairingState: .paired,
trustApprovedAt: record.trustApprovedAt,
fingerprintSource: record.fingerprintSource
)
try upsert(record, for: televisionID)
}
public func deleteToken(for televisionID: UUID) async throws {
guard var record = try optionalRecord(for: televisionID) else { return }
record = TVSecurityRecord(
certificateSHA256: record.certificateSHA256,
pairingState: .revoked,
trustApprovedAt: record.trustApprovedAt,
fingerprintSource: record.fingerprintSource
)
try upsert(record, for: televisionID)
}
public func deleteTrust(for televisionID: UUID) async throws {
guard let record = try optionalRecord(for: televisionID) else { return }
let replacement = TVSecurityRecord(
token: record.token,
pairingState: record.token == nil ? .revoked : .requiresReapproval,
fingerprintSource: "no-approved-certificate"
)
if record.token == nil {
try deleteRecord(for: televisionID)
} else {
try upsert(replacement, for: televisionID)
}
}
public func approveCertificate(
sha256 fingerprint: Data,
for televisionID: UUID,
approvedAt: Date = Date()
) async throws {
guard fingerprint.count == 32 else { throw KeychainError.unexpectedStatus(errSecParam) }
let old = try optionalRecord(for: televisionID) ?? TVSecurityRecord()
let replacement = TVSecurityRecord(
token: old.certificateSHA256 == fingerprint ? old.token : nil,
certificateSHA256: fingerprint,
pairingState: .paired,
trustApprovedAt: approvedAt,
fingerprintSource: "unauthenticated-first-use"
)
try upsert(replacement, for: televisionID)
}
public func validateCertificate(
sha256 fingerprint: Data,
for televisionID: UUID
) async throws -> CertificateTrustValidation {
guard let record = try optionalRecord(for: televisionID), let pinned = record.certificateSHA256 else {
return .notPinned
}
return pinned == fingerprint && record.pairingState == .paired ? .accepted : .requiresReapproval
}
public func securityRecord(for televisionID: UUID) async throws -> TVSecurityRecord? {
do { return try readRecord(for: televisionID) }
catch KeychainError.notFound { return nil }
}
public func forgetTelevision(_ televisionID: UUID) async throws {
try deleteRecord(for: televisionID)
}
private func trustPolicy(for record: TVSecurityRecord) -> TVTrustPolicy {
guard let fingerprint = record.certificateSHA256, fingerprint.count == 32 else { return .systemDefault }
return .pinnedLeafCertificateSHA256(fingerprint)
}
private func readRecord(for televisionID: UUID) throws -> TVSecurityRecord {
let data = try client.read(service: Self.service, account: televisionID.uuidString)
guard data.count <= 16 * 1024 else { throw KeychainError.unexpectedStatus(errSecDecode) }
let record = try decoder.decode(TVSecurityRecord.self, from: data)
if let pin = record.certificateSHA256, pin.count != 32 { throw KeychainError.unexpectedStatus(errSecDecode) }
if let token = record.token, token.isEmpty || token.utf8.count > 4096 { throw KeychainError.unexpectedStatus(errSecDecode) }
return record
}
private func optionalRecord(for televisionID: UUID) throws -> TVSecurityRecord? {
do { return try readRecord(for: televisionID) }
catch KeychainError.notFound { return nil }
}
private func upsert(_ record: TVSecurityRecord, for televisionID: UUID) throws {
let data = try encoder.encode(record)
do { try client.update(service: Self.service, account: televisionID.uuidString, data: data) }
catch KeychainError.notFound { try client.add(service: Self.service, account: televisionID.uuidString, data: data, accessibility: accessibility) }
}
private func deleteRecord(for televisionID: UUID) throws {
do { try client.delete(service: Self.service, account: televisionID.uuidString) }
catch KeychainError.notFound { return }
}
}