Integrate live phone and watch remote prototype with guarded motion control
This commit is contained in:
@@ -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 }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user