Skip to content

Commit

Permalink
macapp: emit security event when system clock or timezone changed
Browse files Browse the repository at this point in the history
  • Loading branch information
jaredh159 committed Oct 14, 2024
1 parent f2b4497 commit 11330b8
Show file tree
Hide file tree
Showing 20 changed files with 315 additions and 21 deletions.
2 changes: 2 additions & 0 deletions api/Sources/Api/PairQL/MacApp/MacApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ extension MacAppRoute: RouteResponder {
case .recentAppVersions:
let output = try await RecentAppVersions.resolve(in: context)
return try await self.respond(with: output)
case .trustedTime:
return try await self.respond(with: get(dependency: \.date.now).timeIntervalSince1970)
}

case .userAuthed(let uuid, let userRoute):
Expand Down
3 changes: 2 additions & 1 deletion api/Sources/Api/PairQL/MacApp/Resolvers/CheckIn.swift
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,8 @@ extension CheckIn: Resolver {
),
browsers: try await browsers.map(\.match),
resolvedFilterSuspension: resolvedFilterSuspension,
resolvedUnlockRequests: resolvedUnlockRequests
resolvedUnlockRequests: resolvedUnlockRequests,
trustedTime: get(dependency: \.date.now).timeIntervalSince1970
)
}
}
5 changes: 5 additions & 0 deletions gertie/Sources/Gertie/SecurityEvent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ public enum SecurityEvent: Equatable, Codable, Sendable {
case appUpdateSucceeded
case appUpdateFailedToReplaceSystemExtension
case advancedSettingsOpened
case systemClockOrTimeZoneChanged
}

public enum Dashboard: String, Codable, Equatable, Sendable {
Expand Down Expand Up @@ -117,6 +118,8 @@ public extension SecurityEvent.MacApp {
return "macOS user exempted"
case .newMacOsUserCreated:
return "New macOS user created"
case .systemClockOrTimeZoneChanged:
return "System clock or time zone changed"
case .systemExtensionChangeRequested:
return "System extension change requested"
case .systemExtensionStateChanged:
Expand Down Expand Up @@ -150,6 +153,8 @@ public extension SecurityEvent.MacApp {
return "This event occurs when a admin-privileged user exempts another macOS user from being filtered by Gertrude. Unless the parent is responsible for this action, it should be investigated."
case .newMacOsUserCreated:
return "This event occurs when a new macOS user is created on the computer. It could represent an attempt to bypass Gertrude, but could also be normal."
case .systemClockOrTimeZoneChanged:
return "This event occurs when the system clock or time zone is changed. If there is not a legitimate reason for the clock or time zone to have changed, it could represent an attempt to circumvent time-based restrictions in Gertrude and should be investigated."
case .systemExtensionChangeRequested:
return "This event occurs whenever some action or process within Gertrude happens that requests a change to the state of the system extension (filter). It should be investigated if the request is to stop or uninstall the extension without it immediately being restarted or replaced."
case .systemExtensionStateChanged:
Expand Down
2 changes: 2 additions & 0 deletions macapp/App/Sources/App/App.swift
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ typealias UserData = GetUserData.Output
self.store.send(.application(.didWake))
case .willTerminate:
self.store.send(.application(.willTerminate))
case .systemClockOrTimeZoneChanged:
self.store.send(.application(.systemClockOrTimeZoneChanged))
}
}
}
Expand Down
16 changes: 16 additions & 0 deletions macapp/App/Sources/App/AppReducer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ import Gertie
import MacAppRoute
import os.log

struct TrustedTimestamp: Equatable {
var network: Date
var system: Date
var boottime: Date

var networkSystemDelta: TimeInterval {
self.network.timeIntervalSince(self.system)
}
}

struct AppReducer: Reducer, Sendable {
struct State: Equatable, Sendable {
var admin = AdminFeature.State()
Expand All @@ -21,6 +31,7 @@ struct AppReducer: Reducer, Sendable {
var monitoring = MonitoringFeature.State()
var requestSuspension = RequestSuspensionFeature.State()
var user = UserFeature.State()
var timestamp: TrustedTimestamp?

init(appVersion: String?) {
self.appUpdates = .init(installedVersion: appVersion)
Expand Down Expand Up @@ -63,6 +74,7 @@ struct AppReducer: Reducer, Sendable {
case requestSuspension(RequestSuspensionFeature.Action)
case startProtecting(user: UserData)
case websocket(WebSocketFeature.Action)
case setTrustedTimestamp(TrustedTimestamp)

indirect case adminAuthed(Action)
}
Expand Down Expand Up @@ -183,6 +195,10 @@ struct AppReducer: Reducer, Sendable {
return .none
}

case .setTrustedTimestamp(let timestamp):
state.timestamp = timestamp
return .none

default:
return .none
}
Expand Down
55 changes: 43 additions & 12 deletions macapp/App/Sources/App/ApplicationFeature.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ public enum ApplicationAction: Equatable, Sendable {
case willSleep
case didWake
case willTerminate
case systemClockOrTimeZoneChanged
}

enum ApplicationFeature {
Expand All @@ -24,6 +25,7 @@ enum ApplicationFeature {
@Dependency(\.filterExtension) var filterExtension
@Dependency(\.mainQueue) var mainQueue
@Dependency(\.network) var network
@Dependency(\.date.now) var now
@Dependency(\.userDefaults) var userDefaults
}
}
Expand All @@ -40,46 +42,46 @@ extension ApplicationFeature.RootReducer: RootReducing {
// await storage.deleteAll()
// }
#endif
let state = try await storage.loadPersistentState()
let state = try await self.storage.loadPersistentState()
await send(.loadedPersistentState(state))
if let deviceId = state?.user?.deviceId {
await api.securityEvent(deviceId: deviceId, event: .appLaunched)
await self.api.securityEvent(deviceId: deviceId, event: .appLaunched)
}
},

.exec { send in
let setupState = await filterExtension.setup()
let setupState = await self.filterExtension.setup()
await send(.filter(.receivedState(setupState)))
if setupState.installed {
_ = await filterXpc.establishConnection()
}
},

.publisher {
filterExtension.stateChanges()
self.filterExtension.stateChanges()
.map { .filter(.receivedState($0)) }
.receive(on: mainQueue)
.receive(on: self.mainQueue)
},

.publisher {
filterXpc.events()
self.filterXpc.events()
.map { .xpc($0) }
.receive(on: mainQueue)
.receive(on: self.mainQueue)
}
)

case .heartbeat(.everyFiveMinutes):
guard network.isConnected() else {
guard self.network.isConnected() else {
return .none
}
return .exec { _ in
guard let bufferedSecurityEvents = try? userDefaults.loadJson(
guard let bufferedSecurityEvents = try? self.userDefaults.loadJson(
at: .bufferedSecurityEventsKey,
decoding: [BufferedSecurityEvent].self
) else { return }
userDefaults.remove(.bufferedSecurityEventsKey)
self.userDefaults.remove(.bufferedSecurityEventsKey)
for buffered in bufferedSecurityEvents {
await api.logSecurityEvent(
await self.api.logSecurityEvent(
.init(
deviceId: buffered.deviceId,
event: buffered.event.rawValue,
Expand All @@ -90,11 +92,40 @@ extension ApplicationFeature.RootReducer: RootReducing {
}
}

case .application(.systemClockOrTimeZoneChanged):
guard let lastTrustedTimestamp = state.timestamp else { return
.none
}
return .exec { _ in
if self.network.isConnected() {
let networkTime = try await self.api.trustedNetworkTimestamp()
let systemTime = self.now.timeIntervalSince1970
let currentDelta = networkTime - systemTime
let expectedDelta = lastTrustedTimestamp.networkSystemDelta
if abs(currentDelta - expectedDelta) > 200 {
await self.api.securityEvent(.systemClockOrTimeZoneChanged)
}
} else if let boottime = self.device.boottime() {
// we have no network to get a trusted timestamp, and we've received a system clock change.
// if the boottime has changed, this is a reasonable indication we can't trust that
// we could infer the network time by using the current time and our last network/system diff
// therefore, we're in a situation where it's reasonable to question if a user is
// attempting to bypass time-based security controls, so notify the parent
// @see https://developer.apple.com/forums/thread/110044?answerId=337069022#337069022
let boottimeDelta = boottime.timeIntervalSince1970
- lastTrustedTimestamp.boottime.timeIntervalSince1970
if abs(boottimeDelta) > 60 * 30 {
await self.api.securityEvent(.systemClockOrTimeZoneChanged, "suspicious bootime change")
}
}
// await self.api.securityEvent(.systemClockOrTimeZoneChanged)
}

case .application(.willTerminate):
return .merge(
.cancel(id: AppReducer.CancelId.heartbeatInterval),
.cancel(id: AppReducer.CancelId.websocketMessages),
.exec { _ in await app.stopRelaunchWatcher() }
.exec { _ in await self.app.stopRelaunchWatcher() }
)

default:
Expand Down
12 changes: 12 additions & 0 deletions macapp/App/Sources/App/CheckInFeature.swift
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import ComposableArchitecture
import Foundation
import MacAppRoute

struct CheckInFeature {
struct RootReducer: RootReducing {
typealias Action = AppReducer.Action
typealias State = AppReducer.State
@Dependency(\.api) var api
@Dependency(\.date.now) var now
@Dependency(\.device) var device
@Dependency(\.filterXpc) var filterXpc
@Dependency(\.network) var network
Expand Down Expand Up @@ -59,6 +61,16 @@ extension CheckInFeature.RootReducer {
.exec { [persist = state.persistent] _ in
try await storage.savePersistentState(persist)
},
.exec { send in
let system = self.now
if let boottime = self.device.boottime() {
await send(.setTrustedTimestamp(.init(
network: Date(timeIntervalSince1970: output.trustedTime),
system: system,
boottime: boottime
)))
}
},
.exec { [filterInstalled = state.filter.extension.installed] _ in
guard filterInstalled else {
if reason == .userRefreshedRules {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ struct DeviceClient: Sendable {
var showNotification: @Sendable (String, String) async -> Void
var serialNumber: @Sendable () -> String?
var username: @Sendable () -> String
var boottime: @Sendable () -> Date?
}

extension DeviceClient: DependencyKey {
Expand All @@ -37,7 +38,17 @@ extension DeviceClient: DependencyKey {
requestNotificationAuthorization: requestNotificationAuth,
showNotification: showNotification(title:body:),
serialNumber: { platform(kIOPlatformSerialNumberKey, format: .string) },
username: { NSUserName() }
username: { NSUserName() },
boottime: {
// https://forums.developer.apple.com/forums/thread/101874?answerId=309633022#309633022
var tv = timeval()
var tvSize = MemoryLayout<timeval>.size
let err = sysctlbyname("kern.boottime", &tv, &tvSize, nil, 0)
guard err == 0, tvSize == MemoryLayout<timeval>.size else {
return nil
}
return Date(timeIntervalSince1970: Double(tv.tv_sec) + (Double(tv.tv_usec) / 1_000_000.0))
}
)
}

Expand All @@ -62,7 +73,8 @@ extension DeviceClient: TestDependencyKey {
),
showNotification: unimplemented("DeviceClient.showNotification"),
serialNumber: unimplemented("DeviceClient.serialNumber", placeholder: ""),
username: unimplemented("DeviceClient.username", placeholder: "")
username: unimplemented("DeviceClient.username", placeholder: ""),
boottime: unimplemented("DeviceClient.boottime", placeholder: nil)
)

static let mock = Self(
Expand All @@ -83,7 +95,8 @@ extension DeviceClient: TestDependencyKey {
requestNotificationAuthorization: {},
showNotification: { _, _ in },
serialNumber: { "test-serial-number" },
username: { "test-username" }
username: { "test-username" },
boottime: { nil }
)
}

Expand Down
5 changes: 5 additions & 0 deletions macapp/App/Sources/ClientInterfaces/ApiClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ public struct ApiClient: Sendable {
public var reportBrowsers: @Sendable (ReportBrowsers.Input) async throws -> Void
public var setAccountActive: @Sendable (Bool) async -> Void
public var setUserToken: @Sendable (UUID) async -> Void
public var trustedNetworkTimestamp: @Sendable () async throws -> Double
public var uploadScreenshot: @Sendable (UploadScreenshotData) async throws -> URL

public init(
Expand All @@ -36,6 +37,7 @@ public struct ApiClient: Sendable {
reportBrowsers: @escaping @Sendable (ReportBrowsers.Input) async throws -> Void,
setAccountActive: @escaping @Sendable (Bool) async -> Void,
setUserToken: @escaping @Sendable (UUID) async -> Void,
trustedNetworkTimestamp: @escaping @Sendable () async throws -> Double,
uploadScreenshot: @escaping @Sendable (UploadScreenshotData) async throws -> URL
) {
self.checkIn = checkIn
Expand All @@ -51,6 +53,7 @@ public struct ApiClient: Sendable {
self.reportBrowsers = reportBrowsers
self.setAccountActive = setAccountActive
self.setUserToken = setUserToken
self.trustedNetworkTimestamp = trustedNetworkTimestamp
self.uploadScreenshot = uploadScreenshot
}
}
Expand Down Expand Up @@ -107,6 +110,7 @@ extension ApiClient: TestDependencyKey {
reportBrowsers: unimplemented("ApiClient.reportBrowsers"),
setAccountActive: unimplemented("ApiClient.setAccountActive"),
setUserToken: unimplemented("ApiClient.setUserToken"),
trustedNetworkTimestamp: unimplemented("ApiClient.trustedNetworkTimestamp"),
uploadScreenshot: unimplemented("ApiClient.uploadScreenshot")
)

Expand All @@ -124,6 +128,7 @@ extension ApiClient: TestDependencyKey {
reportBrowsers: { _ in },
setAccountActive: { _ in },
setUserToken: { _ in },
trustedNetworkTimestamp: { 0.0 },
uploadScreenshot: { _ in .init(string: "https://s3.buck.et/img.png")! }
)
}
Expand Down
6 changes: 6 additions & 0 deletions macapp/App/Sources/LiveApiClient/LiveApiClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,12 @@ extension ApiClient: DependencyKey {
},
setAccountActive: { await accountActive.setValue($0) },
setUserToken: { await userToken.setValue($0) },
trustedNetworkTimestamp: {
try await output(
from: TrustedTime.self,
withUnauthed: .trustedTime
)
},
uploadScreenshot: { data in
guard await accountActive.value else { throw Error.accountInactive }
let signed = try await output(
Expand Down
10 changes: 10 additions & 0 deletions macapp/App/Tests/AppTests/AppReducerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ final class AppReducerTests: XCTestCase {
store.deps.app.enableLaunchAtLogin = enableLaunchAtLogin.fn
let startRelaunchWatcher = mock(always: ())
store.deps.app.startRelaunchWatcher = startRelaunchWatcher.fn
store.deps.device.boottime = { .reference - 60 }
store.deps.date = .constant(.reference)

await store.send(.application(.didFinishLaunching))

Expand Down Expand Up @@ -58,6 +60,14 @@ final class AppReducerTests: XCTestCase {
$0.browsers = CheckIn.Output.mock.browsers
}

let timestamp = TrustedTimestamp(
network: .epoch,
system: .reference,
boottime: .reference - 60
)
await store.receive(.setTrustedTimestamp(timestamp)) {
$0.timestamp = timestamp
}
await store.receive(.user(.updated(previous: prevUser)))

filterStateSubject.send(.notInstalled)
Expand Down
5 changes: 5 additions & 0 deletions macapp/App/Tests/AppTests/Mocks.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,8 @@ extension MacOSUser {
static let dad = MacOSUser(id: 501, name: "Dad", type: .admin)
static let franny = MacOSUser(id: 502, name: "Franny", type: .standard)
}

public extension Date {
static let epoch = Date(timeIntervalSince1970: 0)
static let reference = Date(timeIntervalSinceReferenceDate: 0)
}
Loading

0 comments on commit 11330b8

Please sign in to comment.