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