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
+21
View File
@@ -0,0 +1,21 @@
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "SecureStorage",
platforms: [.iOS(.v17), .macOS(.v14)],
products: [.library(name: "SecureStorage", targets: ["SecureStorage"])],
dependencies: [.package(path: "../TVTransport")],
targets: [
.target(
name: "SecureStorage",
dependencies: [
.product(name: "TVTransport", package: "TVTransport")
]
),
.testTarget(
name: "SecureStorageTests",
dependencies: ["SecureStorage"]
)
]
)
+22 -1
View File
@@ -1,3 +1,24 @@
# SecureStorage
Planned adapter: iPhone Keychain token and per-TV trust storage. No live implementation is present. Owner: workstream E in docs/agents/README.md. The integrator must establish injected protocols and source/test target membership before implementation. Keep platform APIs out of pure package modules.
This adapter stores the TV token, certificate pin, and pairing state in the
iPhone Keychain under an opaque television UUID. It is injected through
`KeychainClient` for deterministic tests and conforms to the frozen
`TVAuthorizationStore` protocol from `TVTransport`.
The candidate accessibility is `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`:
it is the narrowest device-only option that can support a phone-lock relay after
the first unlock. It is unavailable before the first unlock after reboot and has
not yet been validated on a signed iPhone. The stronger
`WhenUnlockedThisDeviceOnly` option remains available for a deployment that
does not require locked-phone relay.
Certificate trust is fail-closed. A self-signed certificate is accepted only
after an explicit user approval supplied to `approveCertificate`; the exact
SHA-256 leaf fingerprint is then pinned to that opaque TV ID. A changed
fingerprint returns `requiresReapproval` and is never silently accepted. The
stored source records that first-use fingerprinting came from an unauthenticated
connection, so it is not presented as proof of TV identity.
`forgetTelevision` removes the complete Keychain record. `deleteToken` revokes
pairing while preserving the trust pin; `deleteTrust` removes trust material.
Neither tokens nor trust records are sent to WatchConnectivity or cloud storage.
@@ -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 }
}
}
@@ -0,0 +1,119 @@
import Foundation
import Testing
@testable import SecureStorage
private final class MockKeychainClient: KeychainClient, @unchecked Sendable {
enum Failure: Error, Equatable { case locked, unexpected }
var values: [String: Data] = [:]
var failure: Failure?
var adds = 0
var updates = 0
var deletes = 0
func add(service: String, account: String, data: Data, accessibility: KeychainAccessibility) throws {
if let failure { throw failure == .locked ? KeychainError.locked : KeychainError.unexpectedStatus(-1) }
guard values[account] == nil else { throw KeychainError.unexpectedStatus(-25299) }
values[account] = data; adds += 1
}
func read(service: String, account: String) throws -> Data {
if let failure { throw failure == .locked ? KeychainError.locked : KeychainError.unexpectedStatus(-1) }
guard let value = values[account] else { throw KeychainError.notFound }
return value
}
func update(service: String, account: String, data: Data) throws {
if let failure { throw failure == .locked ? KeychainError.locked : KeychainError.unexpectedStatus(-1) }
guard values[account] != nil else { throw KeychainError.notFound }
values[account] = data; updates += 1
}
func delete(service: String, account: String) throws {
if let failure { throw failure == .locked ? KeychainError.locked : KeychainError.unexpectedStatus(-1) }
guard values.removeValue(forKey: account) != nil else { throw KeychainError.notFound }
deletes += 1
}
}
@Suite("Keychain TV authorization store")
struct KeychainTVAuthorizationStoreTests {
@Test func addReadUpdateDeleteAndNotFound() async throws {
let client = MockKeychainClient()
let store = KeychainTVAuthorizationStore(client: client, accessibility: .whenUnlockedThisDeviceOnly)
let tv = UUID()
#expect(try await store.securityRecord(for: tv) == nil)
try await store.saveToken("token-1", for: tv)
#expect((try await store.authorization(for: tv)).token == "token-1")
try await store.saveToken("token-2", for: tv)
#expect((try await store.authorization(for: tv)).token == "token-2")
try await store.forgetTelevision(tv)
#expect(try await store.securityRecord(for: tv) == nil)
try await store.forgetTelevision(tv)
}
@Test func certificateApprovalPinsAndChangedCertificateRequiresReapproval() async throws {
let client = MockKeychainClient()
let store = KeychainTVAuthorizationStore(client: client)
let tv = UUID()
let first = Data(repeating: 0x01, count: 32)
let changed = Data(repeating: 0x02, count: 32)
#expect(try await store.validateCertificate(sha256: first, for: tv) == .notPinned)
try await store.approveCertificate(sha256: first, for: tv)
#expect(try await store.validateCertificate(sha256: first, for: tv) == .accepted)
#expect(try await store.validateCertificate(sha256: changed, for: tv) == .requiresReapproval)
#expect((try await store.authorization(for: tv)).trust == .pinnedLeafCertificateSHA256(first))
}
@Test func revokePreservesTrustButForgetRemovesBoth() async throws {
let client = MockKeychainClient()
let store = KeychainTVAuthorizationStore(client: client)
let tv = UUID()
let fingerprint = Data(repeating: 0x03, count: 32)
try await store.saveToken("token", for: tv)
try await store.approveCertificate(sha256: fingerprint, for: tv)
try await store.deleteToken(for: tv)
#expect((try await store.authorization(for: tv)).token == nil)
#expect(try await store.validateCertificate(sha256: fingerprint, for: tv) == .requiresReapproval)
try await store.forgetTelevision(tv)
#expect(try await store.securityRecord(for: tv) == nil)
}
@Test func lockedAndUnexpectedSecurityErrorsArePropagated() async throws {
let client = MockKeychainClient()
let store = KeychainTVAuthorizationStore(client: client)
let tv = UUID()
client.setFailure(.locked)
await #expect(throws: KeychainError.locked) { try await store.approveCertificate(sha256: Data(repeating: 0, count: 32), for: tv) }
client.setFailure(.unexpected)
await #expect(throws: KeychainError.unexpectedStatus(-1)) { try await store.approveCertificate(sha256: Data(repeating: 0, count: 32), for: tv) }
}
}
private extension MockKeychainClient {
func setFailure(_ failure: Failure?) { self.failure = failure }
}
@Test func newCertificateApprovalCannotReusePreviousToken() async throws {
let store = KeychainTVAuthorizationStore(client: MockKeychainClient())
let tv = UUID(), first = Data(repeating: 1, count: 32), changed = Data(repeating: 2, count: 32)
try await store.approveCertificate(sha256: first, for: tv)
try await store.saveToken("synthetic-token", for: tv)
try await store.approveCertificate(sha256: changed, for: tv)
let stored = try await store.authorization(for: tv)
#expect(stored.token == nil)
#expect(stored.trust == .pinnedLeafCertificateSHA256(changed))
}
@Test func malformedStoredPinDoesNotFallBackToSystemTrust() async throws {
let client = MockKeychainClient()
let id = UUID()
let invalid = TVSecurityRecord(token: "synthetic", certificateSHA256: Data([1]))
try client.add(service: KeychainTVAuthorizationStore.service, account: id.uuidString,
data: JSONEncoder().encode(invalid), accessibility: .whenUnlockedThisDeviceOnly)
let store = KeychainTVAuthorizationStore(client: client)
await #expect(throws: (any Error).self) { try await store.authorization(for: id) }
}