Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions daemon/Tests/TouchBridgeCoreTests/DaemonCoordinatorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -712,6 +712,84 @@ enum TestSetupError: Error { case ecdhFailed }
_ = await authResult
}

// MARK: - Edge Cases

@Test func authLateResponseAfterTimeoutIsNoOp() async throws {
let (coordinator, bleServer, keychain, _) = makeTestCoordinator()
let companion = CompanionSimulator()
try register(companion, in: keychain)
try await fullyConnect(companion: companion, to: coordinator, via: bleServer)

async let result = coordinator.authenticateFromPAM(user: "arun", service: "sudo", pid: 1, timeout: 0.05)
// Wait past the timeout
try await Task.sleep(nanoseconds: 200_000_000)

// Send a valid response AFTER timeout — OnceContinuation must not crash (no double-resume)
guard let challengeWire = bleServer.sentChallenges.last?.data else {
Issue.record("No challenge was sent")
return
}
let lateResponse = try companion.respondToChallenge(challengeWire)
bleServer.simulateResponse(lateResponse, from: companion.centralID)

let (success, reason) = await result
#expect(success == false)
#expect(reason == "timeout")
}

@Test func authBLESendFailureResultsInImmediateFailure() async throws {
let (coordinator, bleServer, keychain, _) = makeTestCoordinator()
let companion = CompanionSimulator()
try register(companion, in: keychain)
try await fullyConnect(companion: companion, to: coordinator, via: bleServer)

// Simulate BLE transmit queue full — sendChallenge returns false
bleServer.sendSucceeds = false

let start = Date()
let result = await coordinator.authenticateFromPAM(user: "arun", service: "sudo", pid: 1, timeout: 5.0)
let elapsed = Date().timeIntervalSince(start)

#expect(result.success == false)
#expect(result.reason == "challenge_failed")
#expect(elapsed < 1.0) // must fail fast, not after the full 5s timeout
}

@Test func authResponseWithWrongDeviceIDIsIgnored() async throws {
let (coordinator, bleServer, keychain, _) = makeTestCoordinator()
let companion = CompanionSimulator()
try register(companion, in: keychain)
try await fullyConnect(companion: companion, to: coordinator, via: bleServer)

async let result = coordinator.authenticateFromPAM(user: "arun", service: "sudo", pid: 1, timeout: 2.0)
try await Task.sleep(nanoseconds: 100_000_000)

guard let challengeWire = bleServer.sentChallenges.last?.data else {
Issue.record("No challenge was sent")
return
}
let payload = challengeWire.dropFirst(2)
let msg = try WireFormat.decodePayload(ChallengeIssuedMessage.self, from: payload)

// Spoof: valid challengeID but a deviceID that is not in the keychain
let spoofed = ChallengeResponseMessage(
challengeID: msg.challengeID,
signature: Data(repeating: 0, count: 64),
deviceID: "spoofed-unknown-device"
)
let spoofWire = try WireFormat.encode(.challengeResponse, spoofed)
bleServer.simulateResponse(spoofWire, from: companion.centralID)
try await Task.sleep(nanoseconds: 50_000_000) // let coordinator process the spoof

// Correct response should still resolve auth successfully
let goodResponse = try companion.respondToChallenge(challengeWire)
bleServer.simulateResponse(goodResponse, from: companion.centralID)

let (success, reason) = await result
#expect(success == true)
#expect(reason == nil)
}

// MARK: - Identify-on-Reconnect

@Test func reconnectAndReidentifyRestoresAuth() async throws {
Expand Down
175 changes: 175 additions & 0 deletions daemon/Tests/TouchBridgeCoreTests/EndToEndTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,95 @@ private func pamConnect(socketPath: String, user: String, service: String) throw
return String(cString: buffer)
}

/// Auth handler that signs the nonce with a key that is NOT registered in the keychain,
/// so signature verification always returns .invalidSignature.
final class BadSignatureAuthHandler: PAMAuthHandler, @unchecked Sendable {
let challengeManager = ChallengeManager()
let keychainStore: KeychainStore
let auditLog: AuditLog
let deviceID: String

// The registered (stored) key — what the keychain knows about
private let storedPublicKey: Data
// A different key used to sign — deliberately wrong
private let wrongPrivateKey: SecKey

init(keychainService: String) {
self.keychainStore = KeychainStore(service: keychainService)
let logDir = FileManager.default.temporaryDirectory
.appendingPathComponent("tb-e2e-bad-\(UUID().uuidString)")
self.auditLog = AuditLog(logDirectory: logDir)
self.deviceID = UUID().uuidString

let attrs: [String: Any] = [
kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
kSecAttrKeySizeInBits as String: 256,
]
var cfErr: Unmanaged<CFError>?

// Key that goes in the keychain
let storedPrivate = SecKeyCreateRandomKey(attrs as CFDictionary, &cfErr)!
let storedPublic = SecKeyCopyPublicKey(storedPrivate)!
self.storedPublicKey = SecKeyCopyExternalRepresentation(storedPublic, &cfErr)! as Data

// Different key used for signing — NOT in keychain
self.wrongPrivateKey = SecKeyCreateRandomKey(attrs as CFDictionary, &cfErr)!

let device = PairedDevice(
deviceID: deviceID,
publicKey: storedPublicKey,
displayName: "Bad Sig iPhone",
pairedAt: Date()
)
try! keychainStore.storePairedDevice(device)
}

func authenticateFromPAM(user: String, service: String, pid: Int, timeout: TimeInterval) async -> (success: Bool, reason: String?) {
let challenge: Challenge
do {
challenge = try await challengeManager.issue(for: deviceID)
} catch {
return (false, "challenge_issuance_failed")
}

var signError: Unmanaged<CFError>?
guard let signature = SecKeyCreateSignature(
wrongPrivateKey, // wrong key — verify will fail
.ecdsaSignatureMessageX962SHA256,
challenge.nonce as CFData,
&signError
) as Data? else {
return (false, "signing_failed")
}

do {
let publicKey = try keychainStore.retrievePublicKey(for: deviceID)
let result = await challengeManager.verify(
challengeID: challenge.id,
signature: signature,
publicKey: publicKey
)
await auditLog.log(AuditEntry(
sessionID: challenge.id.uuidString,
surface: "pam_\(service)",
requestingProcess: service,
companionDevice: "Bad Sig iPhone",
deviceID: deviceID,
result: result == .verified ? "VERIFIED" : "FAILED",
authType: "biometric",
latencyMs: 50
))
return (result == .verified, result == .verified ? nil : "verification_failed")
} catch {
return (false, "key_retrieval_failed")
}
}

func cleanup() {
try? keychainStore.removeAll()
}
}

// MARK: - Tests

@Test func fullEndToEndSudoFlow() async throws {
Expand Down Expand Up @@ -249,3 +338,89 @@ private func pamConnect(socketPath: String, user: String, service: String) throw
#expect(content.contains("\"session_id\""))
#expect(content.contains("\"VERIFIED\""))
}

@Test func fullEndToEndFailsWithBadSignature() async throws {
let socketPath = makeShortSocketPath()
defer { unlink(socketPath) }

let keychainService = "dev.touchbridge.test.e2e.\(UUID().uuidString)"
let handler = BadSignatureAuthHandler(keychainService: keychainService)
defer { handler.cleanup() }

let server = SocketServer(authHandler: handler, socketPath: socketPath)
try server.start()
defer { server.stop() }

try await Task.sleep(nanoseconds: 100_000_000)

let response = try pamConnect(socketPath: socketPath, user: "arun", service: "sudo")

#expect(response.contains("\"result\":\"failure\""))

let entries = try await handler.auditLog.readEntries()
#expect(entries.count == 1)
#expect(entries[0].result == "FAILED")
}

@Test func fullEndToEndConcurrentRequests() async throws {
let socketPath = makeShortSocketPath()
defer { unlink(socketPath) }

let keychainService = "dev.touchbridge.test.e2e.\(UUID().uuidString)"
let handler = FullFlowAuthHandler(keychainService: keychainService)
defer { handler.cleanup() }

let server = SocketServer(authHandler: handler, socketPath: socketPath)
try server.start()
defer { server.stop() }

try await Task.sleep(nanoseconds: 100_000_000)

// Fire 3 PAM connections concurrently
let results = try await withThrowingTaskGroup(of: String.self) { group in
for i in 0..<3 {
group.addTask {
try pamConnect(socketPath: socketPath, user: "user\(i)", service: "sudo")
}
}
var all: [String] = []
for try await r in group { all.append(r) }
return all
}

for r in results {
#expect(r.contains("\"result\":\"success\""))
}

let entries = try await handler.auditLog.readEntries()
#expect(entries.count == 3)
#expect(entries.allSatisfy { $0.result == "VERIFIED" })
}

@Test func fullEndToEndAuditEntryFields() async throws {
let socketPath = makeShortSocketPath()
defer { unlink(socketPath) }

let keychainService = "dev.touchbridge.test.e2e.\(UUID().uuidString)"
let handler = FullFlowAuthHandler(keychainService: keychainService)
defer { handler.cleanup() }

let server = SocketServer(authHandler: handler, socketPath: socketPath)
try server.start()
defer { server.stop() }

try await Task.sleep(nanoseconds: 100_000_000)

_ = try pamConnect(socketPath: socketPath, user: "arun", service: "sudo")

let entries = try await handler.auditLog.readEntries()
#expect(entries.count == 1)
let e = entries[0]
#expect(UUID(uuidString: e.sessionID) != nil)
#expect(e.requestingProcess == "sudo")
#expect(e.surface == "pam_sudo")
#expect(e.result == "VERIFIED")
#expect(e.authType == "biometric")
#expect((e.latencyMs ?? 0) > 0)
#expect(e.companionDevice == "E2E Test iPhone")
}
48 changes: 48 additions & 0 deletions daemon/Tests/TouchBridgeCoreTests/ProximityMonitorTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import Testing
import Foundation
@testable import TouchBridgeCore

@Test func proximityLockNotTriggeredWhenDisabled() async {
let monitor = ProximityMonitor(rssiThreshold: -80, disconnectDelay: 0.05)
var lockCalled = false
monitor.onShouldLock = { lockCalled = true }
// NOT calling monitor.enable()
monitor.connectionStateChanged(connected: false)
try? await Task.sleep(nanoseconds: 150_000_000)
#expect(!lockCalled)
}

@Test func proximityLockTriggeredAfterDisconnectDelay() async {
let monitor = ProximityMonitor(rssiThreshold: -80, disconnectDelay: 0.05)
var lockCalled = false
monitor.onShouldLock = { lockCalled = true }
monitor.enable()
monitor.connectionStateChanged(connected: false)
try? await Task.sleep(nanoseconds: 150_000_000)
#expect(lockCalled)
}

@Test func proximityLockCancelledOnReconnect() async {
// Use a generous delay (0.5s) and cancel at 200ms — 2.5x margin before the deadline fires.
let monitor = ProximityMonitor(rssiThreshold: -80, disconnectDelay: 0.5)
var lockCalled = false
monitor.onShouldLock = { lockCalled = true }
monitor.enable()
monitor.connectionStateChanged(connected: false)
try? await Task.sleep(nanoseconds: 200_000_000) // 200ms — well before 500ms deadline
monitor.connectionStateChanged(connected: true) // reconnect cancels the timer
try? await Task.sleep(nanoseconds: 600_000_000) // 600ms — original deadline is long past
#expect(!lockCalled)
}

@Test func proximityLockCancelledOnDisable() async {
let monitor = ProximityMonitor(rssiThreshold: -80, disconnectDelay: 0.5)
var lockCalled = false
monitor.onShouldLock = { lockCalled = true }
monitor.enable()
monitor.connectionStateChanged(connected: false)
try? await Task.sleep(nanoseconds: 200_000_000) // 200ms — well before 500ms deadline
monitor.disable()
try? await Task.sleep(nanoseconds: 600_000_000)
#expect(!lockCalled)
}
Loading