diff --git a/Common/SCXPCClient.h b/Common/SCXPCClient.h index dd9f06c3..35f00c5f 100644 --- a/Common/SCXPCClient.h +++ b/Common/SCXPCClient.h @@ -22,6 +22,7 @@ NS_ASSUME_NONNULL_BEGIN - (void)startBlockWithControllingUID:(uid_t)controllingUID blocklist:(NSArray*)blocklist isAllowlist:(BOOL)isAllowlist endDate:(NSDate*)endDate blockSettings:(NSDictionary*)blockSettings reply:(void(^)(NSError* error))reply; - (void)updateBlocklist:(NSArray*)newBlocklist reply:(void(^)(NSError* error))reply; - (void)updateBlockEndDate:(NSDate*)newEndDate reply:(void(^)(NSError* error))reply; +- (void)getBlockUnlockGateStateWithReply:(void(^)(BOOL waiting, NSDate* _Nullable lastAttemptAt, NSString* _Nullable errorReason))reply; @end diff --git a/Common/SCXPCClient.m b/Common/SCXPCClient.m index 5436c73e..a7bb1659 100644 --- a/Common/SCXPCClient.m +++ b/Common/SCXPCClient.m @@ -329,6 +329,20 @@ - (void)updateBlocklist:(NSArray*)newBlocklist reply:(void(^)(NSError }]; } +- (void)getBlockUnlockGateStateWithReply:(void(^)(BOOL waiting, NSDate* lastAttemptAt, NSString* errorReason))reply { + [self connectAndExecuteCommandBlock:^(NSError * connectError) { + if (connectError != nil) { + NSLog(@"Failed to get unlock gate state with connection error: %@", connectError); + reply(NO, nil, connectError.localizedDescription); + } else { + [[self.daemonConnection remoteObjectProxyWithErrorHandler:^(NSError * proxyError) { + NSLog(@"Failed to get unlock gate state with remote object proxy error: %@", proxyError); + reply(NO, nil, proxyError.localizedDescription); + }] getBlockUnlockGateStateWithReply: reply]; + } + }]; +} + - (void)updateBlockEndDate:(NSDate*)newEndDate reply:(void(^)(NSError* error))reply { [self connectAndExecuteCommandBlock:^(NSError * connectError) { if (connectError != nil) { diff --git a/Common/Utility/SCBlockClock.h b/Common/Utility/SCBlockClock.h new file mode 100644 index 00000000..5e0ac714 --- /dev/null +++ b/Common/Utility/SCBlockClock.h @@ -0,0 +1,60 @@ +#import + +NS_ASSUME_NONNULL_BEGIN + +/// Tamper-resistant block-elapsed-time accounting. +/// Combines mach_continuous_time() with a periodic on-disk checkpoint so that +/// changing the system clock with `sudo date` cannot end a block early. +/// +/// All mutating class methods (recordBlockStart, tickCheckpoint) must be invoked +/// serialized — e.g. from the daemon's main runloop. SCSettings @synchronized +/// protects each get/set leg, but not the combined read-modify-write pattern. +@interface SCBlockClock : NSObject + +/// Called once at block start. Writes the BlockTimekeeping dictionary into SCSettings. ++ (void)recordBlockStartWithDuration:(NSTimeInterval)durationSeconds; + +/// As above, but also persists enough block-config to rebuild the block if SCSettings +/// is tampered with (e.g. the stock SelfControl Killer running resetAllSettingsToDefaults). +/// BlockTimekeeping is intentionally NOT in defaultSettingsDict, so it survives that reset. ++ (void)recordBlockStartWithDuration:(NSTimeInterval)durationSeconds + blocklist:(nullable NSArray*)blocklist + isAllowlist:(BOOL)isAllowlist + endDate:(nullable NSDate*)endDate; + +/// Block-config previously stashed by recordBlockStart, for the daemon's tampering- +/// recovery path. Returns nil if no block is recorded or the metadata was never stored. ++ (nullable NSArray*)savedActiveBlocklist; ++ (BOOL)savedActiveBlockAsWhitelist; ++ (nullable NSDate*)savedBlockEndDate; + +/// Erase all block-tracking state. Call after a legitimate block end so a future +/// checkupBlock does not misread stale data as evidence of tampering. ++ (void)clearAllBlockState; + +/// Called every ~30 s by the daemon. Updates elapsedSecondsAccumulated and the +/// last-checkpoint values, using the smaller of the wall-clock delta and the +/// monotonic delta. A negative wall-clock delta credits zero. ++ (void)tickCheckpoint; + +/// Total real seconds elapsed since the block started. Returns 0 if no block. ++ (NSTimeInterval)elapsedSecondsForCurrentBlock; + +/// YES iff elapsedSecondsForCurrentBlock >= the recorded duration. ++ (BOOL)blockDurationHasElapsed; + +/// Recorded total block duration in seconds. Returns 0 if no block is recorded. ++ (NSTimeInterval)blockDurationSeconds; + +/// Seconds left until elapsedSecondsForCurrentBlock reaches blockDurationSeconds. +/// Clamped at 0; returns 0 if no block is recorded. ++ (NSTimeInterval)remainingSecondsForCurrentBlock; + +#ifdef DEBUG +/// Test-only override. Pass nil to clear. ++ (void)setBootSessionUUIDOverrideForTesting:(nullable NSString*)uuid; +#endif + +@end + +NS_ASSUME_NONNULL_END diff --git a/Common/Utility/SCBlockClock.m b/Common/Utility/SCBlockClock.m new file mode 100644 index 00000000..91275be5 --- /dev/null +++ b/Common/Utility/SCBlockClock.m @@ -0,0 +1,187 @@ +#import "SCBlockClock.h" +#import "SCSettings.h" +#import +#import + +static NSString* const kBlockTimekeepingKey = @"BlockTimekeeping"; +static NSString* const kBlockStartWallClockKey = @"blockStartWallClock"; +static NSString* const kBlockStartContinuousKey = @"blockStartContinuousTime"; +static NSString* const kBootSessionUUIDKey = @"bootSessionUUID"; +static NSString* const kBlockDurationSecondsKey = @"blockDurationSeconds"; +static NSString* const kElapsedAccumulatedKey = @"elapsedSecondsAccumulated"; +static NSString* const kLastCheckpointWallKey = @"lastCheckpointWallClock"; +static NSString* const kLastCheckpointContKey = @"lastCheckpointContinuous"; + +// Tamper-recovery fields. Mirrored from SCSettings at block start so the daemon +// can reconstitute the block if the user wipes SCSettings (e.g. via the stock +// SelfControl Killer). These keys live inside BlockTimekeeping, which is not in +// defaultSettingsDict and therefore survives resetAllSettingsToDefaults. +static NSString* const kSavedBlocklistKey = @"savedActiveBlocklist"; +static NSString* const kSavedIsAllowlistKey = @"savedActiveBlockAsWhitelist"; +static NSString* const kSavedEndDateKey = @"savedBlockEndDate"; + +static NSString* sBootUUIDOverride = nil; + +@implementation SCBlockClock + ++ (NSString*)currentBootSessionUUID { + if (sBootUUIDOverride != nil) return sBootUUIDOverride; + struct timeval boottime; + size_t size = sizeof(boottime); + if (sysctlbyname("kern.boottime", &boottime, &size, NULL, 0) != 0) { + return @"unknown"; + } + return [NSString stringWithFormat: @"%ld.%d", (long)boottime.tv_sec, boottime.tv_usec]; +} + +#ifdef DEBUG ++ (void)setBootSessionUUIDOverrideForTesting:(NSString*)uuid { + sBootUUIDOverride = [uuid copy]; +} +#endif + ++ (uint64_t)continuousNanos { + static mach_timebase_info_data_t tb; + static dispatch_once_t once; + dispatch_once(&once, ^{ mach_timebase_info(&tb); }); + // mach_continuous_time() returns mach ticks; convert to nanoseconds via the + // platform timebase (numer/denom). On Apple Silicon ticks != nanoseconds. + return mach_continuous_time() * tb.numer / tb.denom; +} + ++ (void)recordBlockStartWithDuration:(NSTimeInterval)durationSeconds { + [self recordBlockStartWithDuration: durationSeconds + blocklist: nil + isAllowlist: NO + endDate: nil]; +} + ++ (void)recordBlockStartWithDuration:(NSTimeInterval)durationSeconds + blocklist:(NSArray*)blocklist + isAllowlist:(BOOL)isAllowlist + endDate:(NSDate*)endDate { + NSDate* now = [NSDate date]; + uint64_t cont = [self continuousNanos]; + NSMutableDictionary* tk = [@{ + kBlockStartWallClockKey: now, + kBlockStartContinuousKey: @(cont), + kBootSessionUUIDKey: [self currentBootSessionUUID], + kBlockDurationSecondsKey: @(durationSeconds), + kElapsedAccumulatedKey: @(0.0), + kLastCheckpointWallKey: now, + kLastCheckpointContKey: @(cont), + kSavedIsAllowlistKey: @(isAllowlist), + } mutableCopy]; + if (blocklist != nil) tk[kSavedBlocklistKey] = blocklist; + if (endDate != nil) tk[kSavedEndDateKey] = endDate; + [[SCSettings sharedSettings] setValue: tk forKey: kBlockTimekeepingKey]; +} + ++ (NSArray*)savedActiveBlocklist { + NSDictionary* tk = [self readTK]; + NSArray* list = tk[kSavedBlocklistKey]; + return [list isKindOfClass: [NSArray class]] ? list : nil; +} + ++ (BOOL)savedActiveBlockAsWhitelist { + NSDictionary* tk = [self readTK]; + return [tk[kSavedIsAllowlistKey] boolValue]; +} + ++ (NSDate*)savedBlockEndDate { + NSDictionary* tk = [self readTK]; + NSDate* d = tk[kSavedEndDateKey]; + return [d isKindOfClass: [NSDate class]] ? d : nil; +} + ++ (void)clearAllBlockState { + [[SCSettings sharedSettings] setValue: nil forKey: kBlockTimekeepingKey]; +} + ++ (NSDictionary*)readTK { + return [[SCSettings sharedSettings] valueForKey: kBlockTimekeepingKey]; +} + ++ (void)writeTK:(NSDictionary*)tk { + [[SCSettings sharedSettings] setValue: tk forKey: kBlockTimekeepingKey]; +} + ++ (NSTimeInterval)inFlightDeltaFromTK:(NSDictionary*)tk + now:(NSDate*)now + continuousNanos:(uint64_t)cont { + NSDate* lastWall = tk[kLastCheckpointWallKey]; + uint64_t lastCont = [tk[kLastCheckpointContKey] unsignedLongLongValue]; + + NSTimeInterval deltaWall = [now timeIntervalSinceDate: lastWall]; + NSTimeInterval deltaCont = ((double)(cont - lastCont)) / 1e9; + + NSTimeInterval trustedDelta = MIN(deltaCont, MAX(0.0, deltaWall)); + if (trustedDelta < 0) trustedDelta = 0; // defense vs corrupt persisted lastCont + return trustedDelta; +} + ++ (void)tickCheckpoint { + NSDictionary* tk = [self readTK]; + if (tk == nil) return; + + NSDate* now = [NSDate date]; + uint64_t cont = [self continuousNanos]; + NSString* currentBoot = [self currentBootSessionUUID]; + + if (![tk[kBootSessionUUIDKey] isEqualToString: currentBoot]) { + // Cross-boot: monotonic counter has reset. Credit max(0, wall-clock gap). + NSTimeInterval gapWall = [now timeIntervalSinceDate: tk[kLastCheckpointWallKey]]; + NSTimeInterval credit = MAX(0.0, gapWall); + NSTimeInterval newAccum = [tk[kElapsedAccumulatedKey] doubleValue] + credit; + + NSMutableDictionary* updated = [tk mutableCopy]; + updated[kElapsedAccumulatedKey] = @(newAccum); + updated[kLastCheckpointWallKey] = now; + updated[kLastCheckpointContKey] = @(cont); + updated[kBootSessionUUIDKey] = currentBoot; + [self writeTK: updated]; + return; + } + + NSTimeInterval trustedDelta = [self inFlightDeltaFromTK: tk now: now continuousNanos: cont]; + NSTimeInterval newAccum = [tk[kElapsedAccumulatedKey] doubleValue] + trustedDelta; + + NSMutableDictionary* updated = [tk mutableCopy]; + updated[kElapsedAccumulatedKey] = @(newAccum); + updated[kLastCheckpointWallKey] = now; + updated[kLastCheckpointContKey] = @(cont); + [self writeTK: updated]; +} + ++ (NSTimeInterval)elapsedSecondsForCurrentBlock { + NSDictionary* tk = [self readTK]; + if (tk == nil) return 0.0; + if (![tk[kBootSessionUUIDKey] isEqualToString: [self currentBootSessionUUID]]) { + return [tk[kElapsedAccumulatedKey] doubleValue]; + } + return [tk[kElapsedAccumulatedKey] doubleValue] + + [self inFlightDeltaFromTK: tk now: [NSDate date] continuousNanos: [self continuousNanos]]; +} + ++ (BOOL)blockDurationHasElapsed { + NSDictionary* tk = [self readTK]; + if (tk == nil) return NO; + return [self elapsedSecondsForCurrentBlock] >= [tk[kBlockDurationSecondsKey] doubleValue]; +} + ++ (NSTimeInterval)blockDurationSeconds { + NSDictionary* tk = [self readTK]; + if (tk == nil) return 0.0; + return [tk[kBlockDurationSecondsKey] doubleValue]; +} + ++ (NSTimeInterval)remainingSecondsForCurrentBlock { + NSDictionary* tk = [self readTK]; + if (tk == nil) return 0.0; + NSTimeInterval duration = [tk[kBlockDurationSecondsKey] doubleValue]; + NSTimeInterval elapsed = [self elapsedSecondsForCurrentBlock]; + NSTimeInterval remaining = duration - elapsed; + return remaining > 0 ? remaining : 0.0; +} + +@end diff --git a/Common/Utility/SCBlockUtilities.h b/Common/Utility/SCBlockUtilities.h index 3c90ce89..d5a8aecf 100644 --- a/Common/Utility/SCBlockUtilities.h +++ b/Common/Utility/SCBlockUtilities.h @@ -18,6 +18,10 @@ NS_ASSUME_NONNULL_BEGIN + (BOOL)currentBlockIsExpired; +/// Strict check: both wall-clock and monotonic counter say the block is over. +/// Use this in the daemon before tearing down firewall rules. ++ (BOOL)currentBlockIsTrulyExpired; + + (BOOL)blockRulesFoundOnSystem; + (void)removeBlockFromSettings; diff --git a/Common/Utility/SCBlockUtilities.m b/Common/Utility/SCBlockUtilities.m index 81b054e6..a7cdc7b9 100644 --- a/Common/Utility/SCBlockUtilities.m +++ b/Common/Utility/SCBlockUtilities.m @@ -8,6 +8,7 @@ #import "SCBlockUtilities.h" #import "HostFileBlocker.h" #import "PacketFilter.h" +#import "SCBlockClock.h" @implementation SCBlockUtilities @@ -61,6 +62,11 @@ + (BOOL)currentBlockIsExpired { } } ++ (BOOL)currentBlockIsTrulyExpired { + if (![self currentBlockIsExpired]) return NO; + return [SCBlockClock blockDurationHasElapsed]; +} + + (BOOL)blockRulesFoundOnSystem { return [PacketFilter blockFoundInPF] || [HostFileBlocker blockFoundInHostsFile]; } diff --git a/Common/Utility/SCPinnedHosts.h b/Common/Utility/SCPinnedHosts.h new file mode 100644 index 00000000..8369895a --- /dev/null +++ b/Common/Utility/SCPinnedHosts.h @@ -0,0 +1,15 @@ +#import + +NS_ASSUME_NONNULL_BEGIN + +/// Baked-in pin list for the trusted-time HTTPS gate. Each entry is +/// @{ @"host": @"", @"spki": @"" }. +/// The quorum check requires 2 of 3 to succeed, so a single rotation +/// failure does not break the gate. +@interface SCPinnedHosts : NSObject + ++ (NSArray*>*)hosts; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Common/Utility/SCPinnedHosts.m b/Common/Utility/SCPinnedHosts.m new file mode 100644 index 00000000..7a89ff60 --- /dev/null +++ b/Common/Utility/SCPinnedHosts.m @@ -0,0 +1,22 @@ +#import "SCPinnedHosts.h" + +@implementation SCPinnedHosts + ++ (NSArray*>*)hosts { + static NSArray* hosts = nil; + static dispatch_once_t once; + dispatch_once(&once, ^{ + // Pins are SHA-256 (base64) of SecKeyCopyExternalRepresentation() output + // for the leaf certificate's public key — i.e., the raw public-key bytes + // (RSA modulus+exponent, or EC point), NOT the full ASN.1 SPKI. + // To recompute, see the SCTrustedTime pinning delegate in SCTrustedTime.m. + hosts = @[ + @{ @"host": @"www.apple.com", @"spki": @"vGYr5lNKlPJ0wSZMROTsiP+dd8iCbBOe7YZMVoy4cK0=" }, + @{ @"host": @"www.google.com", @"spki": @"u6g6X+7KXDBCQ91pQEWODwB8+hbZW43/7pbjYIAzpNU=" }, + @{ @"host": @"www.cloudflare.com", @"spki": @"b1g23yk9nBZr6VXWRfLBYuSS/kzvrmf565y9Cs+3sNw=" }, + ]; + }); + return hosts; +} + +@end diff --git a/Common/Utility/SCTrustedTime.h b/Common/Utility/SCTrustedTime.h new file mode 100644 index 00000000..8b30fafc --- /dev/null +++ b/Common/Utility/SCTrustedTime.h @@ -0,0 +1,30 @@ +#import + +NS_ASSUME_NONNULL_BEGIN + +/// Trusted-internet time verification for the block unlock gate. +/// All callers must be prepared for any method to return nil/NO on failure. +@interface SCTrustedTime : NSObject + +/// Parses an HTTP IMF-fixdate (RFC 7231 §7.1.1.1). Returns nil on any error. ++ (nullable NSDate*)parseHTTPDateHeader:(nullable NSString*)header; + +typedef void (^SCTrustedTimeCompletion)(NSDate* _Nullable verifiedTime, NSError* _Nullable error); + +/// Fetches https:/// HEAD and returns the time from the Date: header IFF +/// the server's leaf SubjectPublicKeyInfo SHA-256 (base64) matches expectedSPKI. ++ (void)fetchTimeFromHost:(NSString*)host + expectedSPKI:(NSString*)spki + timeout:(NSTimeInterval)timeoutSeconds + completion:(SCTrustedTimeCompletion)completion; + +typedef void (^SCTrustedTimeQuorumCompletion)(BOOL verified, NSDate* _Nullable medianTime, NSError* _Nullable error); + +/// Returns verified=YES iff at least 2 pinned hosts respond, all returned times +/// are within 5 minutes of each other, and the median response is >= threshold. ++ (void)verifyTimeIsAfter:(NSDate*)threshold + completion:(SCTrustedTimeQuorumCompletion)completion; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Common/Utility/SCTrustedTime.m b/Common/Utility/SCTrustedTime.m new file mode 100644 index 00000000..5c2f5ee5 --- /dev/null +++ b/Common/Utility/SCTrustedTime.m @@ -0,0 +1,138 @@ +#import "SCTrustedTime.h" +#import "SCPinnedHosts.h" +#import +#import + +@interface SCTrustedTimePinningDelegate : NSObject +@property (copy) NSString* expectedSPKI; +@end + +@implementation SCTrustedTimePinningDelegate + +- (void)URLSession:(NSURLSession*)session +didReceiveChallenge:(NSURLAuthenticationChallenge*)challenge + completionHandler:(void(^)(NSURLSessionAuthChallengeDisposition, NSURLCredential* _Nullable))ch +{ + if (![challenge.protectionSpace.authenticationMethod isEqualToString: NSURLAuthenticationMethodServerTrust]) { + ch(NSURLSessionAuthChallengeCancelAuthenticationChallenge, nil); + return; + } + SecTrustRef trust = challenge.protectionSpace.serverTrust; + if (trust == NULL) { ch(NSURLSessionAuthChallengeCancelAuthenticationChallenge, nil); return; } + + CFIndex count = SecTrustGetCertificateCount(trust); + BOOL matched = NO; + for (CFIndex i = 0; i < count && !matched; i++) { + SecCertificateRef cert = SecTrustGetCertificateAtIndex(trust, i); + SecKeyRef pubkey = SecCertificateCopyKey(cert); + if (pubkey == NULL) continue; + CFErrorRef err = NULL; + CFDataRef der = SecKeyCopyExternalRepresentation(pubkey, &err); + CFRelease(pubkey); + if (der == NULL) continue; + unsigned char digest[CC_SHA256_DIGEST_LENGTH]; + CC_SHA256(CFDataGetBytePtr(der), (CC_LONG)CFDataGetLength(der), digest); + NSData* digestData = [NSData dataWithBytes: digest length: CC_SHA256_DIGEST_LENGTH]; + NSString* b64 = [digestData base64EncodedStringWithOptions: 0]; + CFRelease(der); + if ([b64 isEqualToString: self.expectedSPKI]) matched = YES; + } + + if (matched) { + ch(NSURLSessionAuthChallengeUseCredential, [NSURLCredential credentialForTrust: trust]); + } else { + ch(NSURLSessionAuthChallengeCancelAuthenticationChallenge, nil); + } +} + +@end + +@implementation SCTrustedTime + ++ (NSDateFormatter*)imfFormatter { + static NSDateFormatter* f = nil; + static dispatch_once_t once; + dispatch_once(&once, ^{ + f = [[NSDateFormatter alloc] init]; + f.locale = [NSLocale localeWithLocaleIdentifier: @"en_US_POSIX"]; + f.timeZone = [NSTimeZone timeZoneWithAbbreviation: @"GMT"]; + f.dateFormat = @"EEE, dd MMM yyyy HH:mm:ss zzz"; + }); + return f; +} + ++ (NSDate*)parseHTTPDateHeader:(NSString*)header { + if (header.length == 0) return nil; + return [[self imfFormatter] dateFromString: header]; +} + ++ (void)fetchTimeFromHost:(NSString*)host + expectedSPKI:(NSString*)spki + timeout:(NSTimeInterval)timeoutSeconds + completion:(SCTrustedTimeCompletion)completion +{ + SCTrustedTimePinningDelegate* d = [SCTrustedTimePinningDelegate new]; + d.expectedSPKI = spki; + NSURLSessionConfiguration* cfg = [NSURLSessionConfiguration ephemeralSessionConfiguration]; + cfg.timeoutIntervalForRequest = timeoutSeconds; + NSURLSession* session = [NSURLSession sessionWithConfiguration: cfg delegate: d delegateQueue: nil]; + + NSURL* url = [NSURL URLWithString: [NSString stringWithFormat: @"https://%@/", host]]; + NSMutableURLRequest* req = [NSMutableURLRequest requestWithURL: url]; + req.HTTPMethod = @"HEAD"; + + [[session dataTaskWithRequest: req completionHandler:^(NSData* data, NSURLResponse* resp, NSError* error) { + [session finishTasksAndInvalidate]; + if (error) { completion(nil, error); return; } + NSHTTPURLResponse* http = (NSHTTPURLResponse*)resp; + NSString* dateStr = http.allHeaderFields[@"Date"]; + NSDate* parsed = [SCTrustedTime parseHTTPDateHeader: dateStr]; + if (parsed == nil) { + completion(nil, [NSError errorWithDomain: @"SCTrustedTime" code: 1 + userInfo: @{NSLocalizedDescriptionKey: @"No/invalid Date header"}]); + return; + } + completion(parsed, nil); + }] resume]; +} + ++ (void)verifyTimeIsAfter:(NSDate*)threshold completion:(SCTrustedTimeQuorumCompletion)completion { + NSArray* hosts = [SCPinnedHosts hosts]; + dispatch_group_t group = dispatch_group_create(); + NSMutableArray* results = [NSMutableArray array]; + NSLock* lock = [NSLock new]; + + for (NSDictionary* hp in hosts) { + dispatch_group_enter(group); + [self fetchTimeFromHost: hp[@"host"] + expectedSPKI: hp[@"spki"] + timeout: 8.0 + completion:^(NSDate* d, NSError* e) { + if (d != nil) { + [lock lock]; [results addObject: d]; [lock unlock]; + } + dispatch_group_leave(group); + }]; + } + + dispatch_group_notify(group, dispatch_get_global_queue(QOS_CLASS_DEFAULT, 0), ^{ + if (results.count < 2) { + completion(NO, nil, [NSError errorWithDomain: @"SCTrustedTime" code: 2 + userInfo: @{NSLocalizedDescriptionKey: @"Quorum not reached"}]); + return; + } + NSArray* sorted = [results sortedArrayUsingSelector: @selector(compare:)]; + NSDate* min = sorted.firstObject; + NSDate* max = sorted.lastObject; + if ([max timeIntervalSinceDate: min] > 300.0) { + completion(NO, nil, [NSError errorWithDomain: @"SCTrustedTime" code: 3 + userInfo: @{NSLocalizedDescriptionKey: @"Pinned hosts disagree"}]); + return; + } + NSDate* median = sorted[sorted.count / 2]; + BOOL ok = [median compare: threshold] != NSOrderedAscending; + completion(ok, median, nil); + }); +} + +@end diff --git a/Daemon/SCDaemon.h b/Daemon/SCDaemon.h index 1e857311..1e837d58 100644 --- a/Daemon/SCDaemon.h +++ b/Daemon/SCDaemon.h @@ -30,6 +30,9 @@ NS_ASSUME_NONNULL_BEGIN // no block running, because we should have checkups going for all blocks) - (void)stopCheckupTimer; +- (void)startCheckpointTimer; +- (void)stopCheckpointTimer; + // Lets the daemon know that there was recent activity // so we can reset our inactivity timer. // The daemon will die if goes for too long without activity. diff --git a/Daemon/SCDaemon.m b/Daemon/SCDaemon.m index 3fd746a2..2e6bbbb8 100644 --- a/Daemon/SCDaemon.m +++ b/Daemon/SCDaemon.m @@ -10,6 +10,7 @@ #import "SCDaemonXPC.h" #import"SCDaemonBlockMethods.h" #import "SCFileWatcher.h" +#import "SCBlockClock.h" static NSString* serviceName = @"org.eyebeam.selfcontrold"; float const INACTIVITY_LIMIT_SECS = 60 * 2; // 2 minutes @@ -25,6 +26,7 @@ @interface SCDaemon () @property (nonatomic, strong, readwrite) NSXPCListener* listener; @property (strong, readwrite) NSTimer* checkupTimer; +@property (strong, readwrite) NSTimer* checkpointTimer; @property (strong, readwrite) NSTimer* inactivityTimer; @property (nonatomic, strong, readwrite) NSDate* lastActivityDate; @@ -100,11 +102,26 @@ - (void)stopCheckupTimer { if (self.checkupTimer == nil) { return; } - + [self.checkupTimer invalidate]; self.checkupTimer = nil; } +- (void)startCheckpointTimer { + if (self.checkpointTimer != nil) return; + self.checkpointTimer = [NSTimer scheduledTimerWithTimeInterval: 30.0 + repeats: YES + block: ^(NSTimer* _Nonnull t) { + [SCBlockClock tickCheckpoint]; + }]; +} + +- (void)stopCheckpointTimer { + if (self.checkpointTimer == nil) return; + [self.checkpointTimer invalidate]; + self.checkpointTimer = nil; +} + - (void)startInactivityTimer { self.inactivityTimer = [NSTimer scheduledTimerWithTimeInterval: 15.0 repeats: YES block:^(NSTimer * _Nonnull timer) { @@ -133,6 +150,10 @@ - (void)dealloc { [self.checkupTimer invalidate]; self.checkupTimer = nil; } + if (self.checkpointTimer) { + [self.checkpointTimer invalidate]; + self.checkpointTimer = nil; + } if (self.inactivityTimer) { [self.inactivityTimer invalidate]; self.inactivityTimer = nil; diff --git a/Daemon/SCDaemonBlockMethods.h b/Daemon/SCDaemonBlockMethods.h index ca1ccf6d..80146be5 100644 --- a/Daemon/SCDaemonBlockMethods.h +++ b/Daemon/SCDaemonBlockMethods.h @@ -31,6 +31,8 @@ NS_ASSUME_NONNULL_BEGIN + (void)checkBlockIntegrity; ++ (void)attemptVerifiedUnlock; + @end NS_ASSUME_NONNULL_END diff --git a/Daemon/SCDaemonBlockMethods.m b/Daemon/SCDaemonBlockMethods.m index 1a6cf0a5..d6f9c3aa 100644 --- a/Daemon/SCDaemonBlockMethods.m +++ b/Daemon/SCDaemonBlockMethods.m @@ -13,9 +13,13 @@ #import "SCDaemon.h" #import "LaunchctlHelper.h" #import "HostFileBlockerSet.h" +#import "SCBlockClock.h" +#import "SCTrustedTime.h" NSTimeInterval METHOD_LOCK_TIMEOUT = 5.0; NSTimeInterval CHECKUP_LOCK_TIMEOUT = 0.5; // use a shorter lock timeout for checkups, because we'd prefer not to have tons pile up +static NSTimeInterval const kVerifyBackoffs[] = { 10.0, 30.0, 60.0, 120.0, 120.0 }; +static const NSUInteger kVerifyBackoffsCount = sizeof(kVerifyBackoffs) / sizeof(kVerifyBackoffs[0]); @implementation SCDaemonBlockMethods @@ -85,7 +89,17 @@ + (void)startBlockWithControllingUID:(uid_t)controllingUID blocklist:(NSArray 0) { + // Persist enough block-config alongside the timekeeping data so we can + // rebuild the block if SCSettings is later wiped (e.g. by the stock + // SelfControl Killer's resetAllSettingsToDefaults). + [SCBlockClock recordBlockStartWithDuration: duration + blocklist: blocklist + isAllowlist: isAllowlist + endDate: endDate]; + } + // update all the settings for the block, which we're basically just copying from defaults to settings [settings setValue: blockSettings[@"ClearCaches"] forKey: @"ClearCaches"]; [settings setValue: blockSettings[@"AllowLocalNetworks"] forKey: @"AllowLocalNetworks"]; @@ -129,6 +143,7 @@ + (void)startBlockWithControllingUID:(uid_t)controllingUID blocklist:(NSArray* savedBlocklist = [SCBlockClock savedActiveBlocklist]; + BOOL savedIsAllowlist = [SCBlockClock savedActiveBlockAsWhitelist]; + NSDate* savedEndDate = [SCBlockClock savedBlockEndDate]; + BOOL haveSavedConfig = (savedBlocklist.count > 0 || savedIsAllowlist); + + if (haveSavedConfig && ![SCBlockClock blockDurationHasElapsed]) { + NSLog(@"INFO: Checkup ran, settings were wiped but BlockTimekeeping still shows an active block. Restoring from saved config."); + [SCSentry captureMessage: @"Tampering detected (settings wiped). Restoring block from BlockTimekeeping."]; + + SCSettings* settings = [SCSettings sharedSettings]; + [settings setValue: savedBlocklist forKey: @"ActiveBlocklist"]; + [settings setValue: @(savedIsAllowlist) forKey: @"ActiveBlockAsWhitelist"]; + if (savedEndDate != nil) { + [settings setValue: savedEndDate forKey: @"BlockEndDate"]; + } + + [SCHelperToolUtilities installBlockRulesFromSettings]; + [settings setValue: @YES forKey: @"BlockIsRunning"]; + + NSError* syncErr = [settings syncSettingsAndWait: 5]; + if (syncErr != nil) { + NSLog(@"WARNING: Sync failed after restoring block: %@", syncErr); + [SCSentry captureError: syncErr]; + } + + [SCHelperToolUtilities sendConfigurationChangedNotification]; + } else { + // No saved config, or the block has legitimately elapsed: existing behavior. + NSLog(@"INFO: Checkup ran, no active block found."); + [SCSentry captureMessage: @"Checkup ran and no active block found! Removing block, tampering suspected..."]; + [SCHelperToolUtilities removeBlock]; + [SCBlockClock clearAllBlockState]; + [SCHelperToolUtilities sendConfigurationChangedNotification]; + + // Temporarily disabled the TamperingDetection flag because it was sometimes causing false positives + // (i.e. people having the background set repeatedly despite no attempts to cheat) + // GitHub issue: https://github.com/SelfControlApp/selfcontrol/issues/621 + // [settings setValue: @YES forKey: @"TamperingDetected"]; + + // once the checkups stop, the daemon will clear itself in a while due to inactivity + [[SCDaemon sharedDaemon] stopCheckupTimer]; + } + } else if ([SCBlockUtilities currentBlockIsTrulyExpired]) { + [SCDaemonBlockMethods attemptVerifiedUnlock]; } else if ([[NSDate date] timeIntervalSinceDate: lastBlockIntegrityCheck] > integrityCheckIntervalSecs) { lastBlockIntegrityCheck = [NSDate date]; // The block is still on. Every once in a while, we should @@ -381,8 +412,48 @@ + (void)checkBlockIntegrity { [SCSentry addBreadcrumb: @"Daemon found compromised block integrity and re-added rules" category: @"daemon"]; NSLog(@"INFO: Integrity check ran; readded block rules."); } else NSLog(@"INFO: Integrity check ran; no action needed."); - + [self.daemonMethodLock unlock]; } ++ (void)attemptVerifiedUnlock { + SCSettings* settings = [SCSettings sharedSettings]; + NSDate* endDate = [settings valueForKey: @"BlockEndDate"]; + NSUInteger attempt = [[settings valueForKey: @"BlockUnlockAttempt"] unsignedIntegerValue]; + + NSMutableDictionary* gate = [[settings valueForKey: @"BlockUnlockGate"] mutableCopy] ?: [NSMutableDictionary dictionary]; + gate[@"waitingForNetworkVerification"] = @YES; + gate[@"lastNetworkAttemptAt"] = [NSDate date]; + [settings setValue: gate forKey: @"BlockUnlockGate"]; + [SCHelperToolUtilities sendConfigurationChangedNotification]; + + [SCTrustedTime verifyTimeIsAfter: endDate completion:^(BOOL ok, NSDate* med, NSError* err) { + if (ok) { + NSLog(@"INFO: Verified unlock — removing block."); + [settings setValue: nil forKey: @"BlockUnlockGate"]; + [settings setValue: @(0) forKey: @"BlockUnlockAttempt"]; + [SCHelperToolUtilities removeBlock]; + // Clear timekeeping after a legitimate end so the next checkupBlock + // does not misread stale saved-config as evidence of tampering. + [SCBlockClock clearAllBlockState]; + [SCHelperToolUtilities sendConfigurationChangedNotification]; + [SCSentry addBreadcrumb: @"Daemon performed verified unlock" category: @"daemon"]; + [[SCDaemon sharedDaemon] stopCheckupTimer]; + [[SCDaemon sharedDaemon] stopCheckpointTimer]; + return; + } + + NSLog(@"WARN: Trusted-time verification failed: %@. Block stays on.", err); + NSMutableDictionary* g = [[settings valueForKey: @"BlockUnlockGate"] mutableCopy] ?: [NSMutableDictionary dictionary]; + g[@"lastNetworkErrorReason"] = err.localizedDescription ?: @"unknown"; + [settings setValue: g forKey: @"BlockUnlockGate"]; + [settings setValue: @(attempt + 1) forKey: @"BlockUnlockAttempt"]; + + NSTimeInterval backoff = kVerifyBackoffs[ MIN(attempt, kVerifyBackoffsCount - 1) ]; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(backoff * NSEC_PER_SEC)), + dispatch_get_global_queue(QOS_CLASS_DEFAULT, 0), + ^{ [SCDaemonBlockMethods attemptVerifiedUnlock]; }); + }]; +} + @end diff --git a/Daemon/SCDaemonProtocol.h b/Daemon/SCDaemonProtocol.h index 07ccbdef..e631a43c 100644 --- a/Daemon/SCDaemonProtocol.h +++ b/Daemon/SCDaemonProtocol.h @@ -23,6 +23,9 @@ NS_ASSUME_NONNULL_BEGIN // XPC method to get version of the installed daemon - (void)getVersionWithReply:(void(^)(NSString * version))reply; +// XPC method to read the block unlock-gate state (no authorization required) +- (void)getBlockUnlockGateStateWithReply:(void(^)(BOOL waitingForNetwork, NSDate* _Nullable lastAttemptAt, NSString* _Nullable errorReason))reply; + @end NS_ASSUME_NONNULL_END diff --git a/Daemon/SCDaemonXPC.m b/Daemon/SCDaemonXPC.m index ad9f09c1..a962360e 100644 --- a/Daemon/SCDaemonXPC.m +++ b/Daemon/SCDaemonXPC.m @@ -74,4 +74,13 @@ - (void)getVersionWithReply:(void(^)(NSString * version))reply { reply(SELFCONTROL_VERSION_STRING); } +// Returns the daemon's unlock-gate state. Anyone can read this; no authorization required. +- (void)getBlockUnlockGateStateWithReply:(void(^)(BOOL waitingForNetwork, NSDate* lastAttemptAt, NSString* errorReason))reply { + NSDictionary* gate = [[SCSettings sharedSettings] valueForKey: @"BlockUnlockGate"]; + BOOL waiting = [gate[@"waitingForNetworkVerification"] boolValue]; + NSDate* at = gate[@"lastNetworkAttemptAt"]; + NSString* err = gate[@"lastNetworkErrorReason"]; + reply(waiting, at, err); +} + @end diff --git a/SCKillerHelper/main.m b/SCKillerHelper/main.m index 18eb5fdc..e08aff1e 100644 --- a/SCKillerHelper/main.m +++ b/SCKillerHelper/main.m @@ -17,6 +17,7 @@ #import "SCMigrationUtilities.h" #import #import "SCSentry.h" +#import "SCBlockClock.h" #define LOG_FILE @"~/Documents/SelfControl-Killer.log" @@ -67,6 +68,17 @@ int main(int argc, char* argv[]) { // depite the EUID being 0 as expected - not sure why that is setuid(0); + // Refuse to wipe an in-progress block: this killer is a *recovery* tool, not + // a bypass. If SCBlockClock says we have a recorded block that has not yet + // elapsed, exit early with EX_TEMPFAIL so the caller can wait and retry. + if ([SCBlockClock blockDurationSeconds] > 0 && ![SCBlockClock blockDurationHasElapsed]) { + NSLog(@"ERROR: Refusing to clear block — block is still active (elapsed %.0fs of %.0fs).", + [SCBlockClock elapsedSecondsForCurrentBlock], + [SCBlockClock blockDurationSeconds]); + [SCSentry captureMessage: @"SCKillerHelper refused: block still active per SCBlockClock"]; + exit(EX_TEMPFAIL); + } + /* FIRST TASK: print debug info */ // print SC version: diff --git a/SelfControl.xcodeproj/project.pbxproj b/SelfControl.xcodeproj/project.pbxproj index 2954aea5..3de3190b 100644 --- a/SelfControl.xcodeproj/project.pbxproj +++ b/SelfControl.xcodeproj/project.pbxproj @@ -7,11 +7,22 @@ objects = { /* Begin PBXBuildFile section */ + 0B8DE13F1AC76A3492243EF7 /* SCTrustedTimeTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 775932FDEC5FE972E702D6CE /* SCTrustedTimeTests.m */; }; + 1F62DFC8BCDB5A0494F70778 /* SCBlockClock.m in Sources */ = {isa = PBXBuildFile; fileRef = D483750E234BD131B4D3D705 /* SCBlockClock.m */; }; + 3B32075B954DBAA7CDF5BE74 /* SCBlockClock.m in Sources */ = {isa = PBXBuildFile; fileRef = D483750E234BD131B4D3D705 /* SCBlockClock.m */; }; + 3D1962F9249CDADACC7209B6 /* SCPinnedHosts.m in Sources */ = {isa = PBXBuildFile; fileRef = 5B7620E0568062C60FE36D3B /* SCPinnedHosts.m */; }; + 4E79FF9C6273791588F4AAE7 /* SCBlockClockTests.m in Sources */ = {isa = PBXBuildFile; fileRef = D16D417A66377222AE733FD2 /* SCBlockClockTests.m */; }; + 53A83D6C78E6B28B265DB92A /* SCTrustedTime.m in Sources */ = {isa = PBXBuildFile; fileRef = 7DEFB5DF64C2C167C77F1936 /* SCTrustedTime.m */; }; 5E6BEEBB5C6E29DADDB344CF /* libPods-selfcontrol-cli.a in Frameworks */ = {isa = PBXBuildFile; fileRef = C85A094F15E20A0DB58665A8 /* libPods-selfcontrol-cli.a */; }; 63BAC9E58A69B15D342B0E29 /* libPods-org.eyebeam.selfcontrold.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 8BF3973D41997900DF147B24 /* libPods-org.eyebeam.selfcontrold.a */; }; + 814B43720F59039AD312D69F /* SCPinnedHosts.m in Sources */ = {isa = PBXBuildFile; fileRef = 5B7620E0568062C60FE36D3B /* SCPinnedHosts.m */; }; + 85BDD37D005DD18B0FEF9609 /* SCBlockClock.m in Sources */ = {isa = PBXBuildFile; fileRef = D483750E234BD131B4D3D705 /* SCBlockClock.m */; }; 8CA8987104D2956493D6AF6B /* Pods_SelfControl_SelfControlTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F41DEF1E3926B4CF3AE2B76C /* Pods_SelfControl_SelfControlTests.framework */; }; 8D11072D0486CEB800E47090 /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 29B97316FDCFA39411CA2CEA /* main.m */; settings = {ATTRIBUTES = (); }; }; 8D11072F0486CEB800E47090 /* Cocoa.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1058C7A1FEA54F0111CA2CBB /* Cocoa.framework */; }; + A2EE8CF4722D0E9AF10DFFCB /* SCTrustedTime.m in Sources */ = {isa = PBXBuildFile; fileRef = 7DEFB5DF64C2C167C77F1936 /* SCTrustedTime.m */; }; + BA690D1AD4806A4CEF2922DB /* SCTrustedTime.m in Sources */ = {isa = PBXBuildFile; fileRef = 7DEFB5DF64C2C167C77F1936 /* SCTrustedTime.m */; }; + C9CFA33CD08F20F878270FBF /* SCPinnedHosts.m in Sources */ = {isa = PBXBuildFile; fileRef = 5B7620E0568062C60FE36D3B /* SCPinnedHosts.m */; }; CB0385E119D77051004614B6 /* PreferencesAdvancedViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = CB0385DD19D77051004614B6 /* PreferencesAdvancedViewController.xib */; }; CB0385E219D77051004614B6 /* PreferencesGeneralViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = CB0385DF19D77051004614B6 /* PreferencesGeneralViewController.xib */; }; CB066F6C2652037E0076964D /* HostFileBlocker.m in Sources */ = {isa = PBXBuildFile; fileRef = CBB0AE290FA74566006229B3 /* HostFileBlocker.m */; }; @@ -206,9 +217,12 @@ CBED7D9925ABB911003080D6 /* selfcontrol-cli in Copy Executable Helper Tools */ = {isa = PBXBuildFile; fileRef = CBA2AFD20F39EC12005AFEBE /* selfcontrol-cli */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; CBEE50C10F48C21F00F5DF1C /* TimerWindowController.m in Sources */ = {isa = PBXBuildFile; fileRef = CBEE50C00F48C21F00F5DF1C /* TimerWindowController.m */; }; CBF3B574217BADD7006D5F52 /* SCSettings.m in Sources */ = {isa = PBXBuildFile; fileRef = CBF3B573217BADD7006D5F52 /* SCSettings.m */; }; + D26FC97A4313152D82B52636 /* SCBlockClock.m in Sources */ = {isa = PBXBuildFile; fileRef = D483750E234BD131B4D3D705 /* SCBlockClock.m */; }; D4EDD26C770910569C31D36F /* libPods-SCKillerHelper.a in Frameworks */ = {isa = PBXBuildFile; fileRef = AF899A50A17F0C6C8C6B84A2 /* libPods-SCKillerHelper.a */; }; + D53DB30311254599F589F6E9 /* SCBlockClock.m in Sources */ = {isa = PBXBuildFile; fileRef = D483750E234BD131B4D3D705 /* SCBlockClock.m */; }; DC4DBA9148D8D67A11899C5E /* Pods_SelfControl.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6EBDE7B29D92764A409E4FDA /* Pods_SelfControl.framework */; }; E263B809965135813A557CD5 /* Pods_SelfControl_Killer.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 86DAD6532C67CBE72E99084C /* Pods_SelfControl_Killer.framework */; }; + EC65D5055C5CDFCECDB7FE91 /* SCBlockClock.m in Sources */ = {isa = PBXBuildFile; fileRef = D483750E234BD131B4D3D705 /* SCBlockClock.m */; }; F5B8CBEE19EE21C30026F3A5 /* SCTimeIntervalFormatter.m in Sources */ = {isa = PBXBuildFile; fileRef = F5B8CBED19EE21C30026F3A5 /* SCTimeIntervalFormatter.m */; }; /* End PBXBuildFile section */ @@ -529,22 +543,28 @@ 29B97325FDCFA39411CA2CEA /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = /System/Library/Frameworks/Foundation.framework; sourceTree = ""; }; 32B26CAAF2E2B648B3C0E892 /* Pods-SelfControl.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SelfControl.debug.xcconfig"; path = "Pods/Target Support Files/Pods-SelfControl/Pods-SelfControl.debug.xcconfig"; sourceTree = ""; }; 32CA4F630368D1EE00C91783 /* SelfControl_Prefix.pch */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = SelfControl_Prefix.pch; sourceTree = ""; }; + 3310B533DAE070805ECF5C66 /* SCPinnedHosts.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = SCPinnedHosts.h; sourceTree = ""; }; 3EF418F71CC7F7FA002D99E8 /* nl */ = {isa = PBXFileReference; fileEncoding = 10; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = ""; }; 3EF418F81CC7F7FA002D99E8 /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/InfoPlist.strings; sourceTree = ""; }; 50CA92A5EB2C31792CA00641 /* Pods-SelfControl Killer.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SelfControl Killer.debug.xcconfig"; path = "Pods/Target Support Files/Pods-SelfControl Killer/Pods-SelfControl Killer.debug.xcconfig"; sourceTree = ""; }; + 5B7620E0568062C60FE36D3B /* SCPinnedHosts.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; path = SCPinnedHosts.m; sourceTree = ""; }; 5FA850E53EB64C54A1404AEF /* Pods-SelfControl.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SelfControl.release.xcconfig"; path = "Pods/Target Support Files/Pods-SelfControl/Pods-SelfControl.release.xcconfig"; sourceTree = ""; }; 640D40CB16D6E962003034B3 /* it */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Localizable.strings; sourceTree = ""; }; 640D40CC16D6E962003034B3 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/InfoPlist.strings; sourceTree = ""; }; 64682B3371BA0A5044028058 /* Pods-selfcontrol-cli.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-selfcontrol-cli.debug.xcconfig"; path = "Pods/Target Support Files/Pods-selfcontrol-cli/Pods-selfcontrol-cli.debug.xcconfig"; sourceTree = ""; }; 69102D5CD4E9672D0EFBF25B /* Pods-selfcontrol-cli.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-selfcontrol-cli.release.xcconfig"; path = "Pods/Target Support Files/Pods-selfcontrol-cli/Pods-selfcontrol-cli.release.xcconfig"; sourceTree = ""; }; 6EBDE7B29D92764A409E4FDA /* Pods_SelfControl.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_SelfControl.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 775932FDEC5FE972E702D6CE /* SCTrustedTimeTests.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; path = SCTrustedTimeTests.m; sourceTree = ""; }; + 7780059D90429CAD9D925F31 /* SCBlockClock.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = SCBlockClock.h; sourceTree = ""; }; 77CFA479BA7C3ECBC15F96D7 /* Pods-org.eyebeam.selfcontrold.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-org.eyebeam.selfcontrold.debug.xcconfig"; path = "Pods/Target Support Files/Pods-org.eyebeam.selfcontrold/Pods-org.eyebeam.selfcontrold.debug.xcconfig"; sourceTree = ""; }; + 7DEFB5DF64C2C167C77F1936 /* SCTrustedTime.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; path = SCTrustedTime.m; sourceTree = ""; }; 86DAD6532C67CBE72E99084C /* Pods_SelfControl_Killer.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_SelfControl_Killer.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 8BF3973D41997900DF147B24 /* libPods-org.eyebeam.selfcontrold.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-org.eyebeam.selfcontrold.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 8D1107310486CEB800E47090 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 8D1107320486CEB800E47090 /* SelfControl.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SelfControl.app; sourceTree = BUILT_PRODUCTS_DIR; }; 949F9F8815B32EC6007B8B42 /* zh-Hans */ = {isa = PBXFileReference; fileEncoding = 10; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = ""; }; 949F9F8D15B333A1007B8B42 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/InfoPlist.strings"; sourceTree = ""; }; + A0ABF5538520738322E1D87F /* SCTrustedTime.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = SCTrustedTime.h; sourceTree = ""; }; AF899A50A17F0C6C8C6B84A2 /* libPods-SCKillerHelper.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-SCKillerHelper.a"; sourceTree = BUILT_PRODUCTS_DIR; }; C72025F2499F9F07E281CB50 /* Pods-SelfControl-SelfControlTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SelfControl-SelfControlTests.release.xcconfig"; path = "Pods/Target Support Files/Pods-SelfControl-SelfControlTests/Pods-SelfControl-SelfControlTests.release.xcconfig"; sourceTree = ""; }; C85A094F15E20A0DB58665A8 /* libPods-selfcontrol-cli.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-selfcontrol-cli.a"; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -1464,6 +1484,8 @@ CBF3B572217BADD7006D5F52 /* SCSettings.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SCSettings.h; sourceTree = ""; }; CBF3B573217BADD7006D5F52 /* SCSettings.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SCSettings.m; sourceTree = ""; }; D0BABA9759C378EE7C619F91 /* Pods-SCKillerHelper.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SCKillerHelper.debug.xcconfig"; path = "Pods/Target Support Files/Pods-SCKillerHelper/Pods-SCKillerHelper.debug.xcconfig"; sourceTree = ""; }; + D16D417A66377222AE733FD2 /* SCBlockClockTests.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; path = SCBlockClockTests.m; sourceTree = ""; }; + D483750E234BD131B4D3D705 /* SCBlockClock.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; path = SCBlockClock.m; sourceTree = ""; }; E1139D5A5B92C88FFF62718F /* Pods-SCKillerHelper.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SCKillerHelper.release.xcconfig"; path = "Pods/Target Support Files/Pods-SCKillerHelper/Pods-SCKillerHelper.release.xcconfig"; sourceTree = ""; }; E3346B8A670C55C686428776 /* Pods-SelfControl-SelfControlTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SelfControl-SelfControlTests.debug.xcconfig"; path = "Pods/Target Support Files/Pods-SelfControl-SelfControlTests/Pods-SelfControl-SelfControlTests.debug.xcconfig"; sourceTree = ""; }; EBDF1B23AA44B10313D79203 /* Pods-SelfControl Killer.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SelfControl Killer.release.xcconfig"; path = "Pods/Target Support Files/Pods-SelfControl Killer/Pods-SelfControl Killer.release.xcconfig"; sourceTree = ""; }; @@ -1580,7 +1602,7 @@ name = Products; sourceTree = ""; }; - 29B97314FDCFA39411CA2CEA /* SelfControl */ = { + 29B97314FDCFA39411CA2CEA = { isa = PBXGroup; children = ( CBDFFF4B24A07DB300622CEE /* SelfControl.entitlements */, @@ -1658,6 +1680,8 @@ children = ( CB0EEF7720FE49020024D27B /* SCUtilityTests.m */, CB0EEF6120FD8CE00024D27B /* Info.plist */, + D16D417A66377222AE733FD2 /* SCBlockClockTests.m */, + 775932FDEC5FE972E702D6CE /* SCTrustedTimeTests.m */, ); path = SelfControlTests; sourceTree = ""; @@ -1758,6 +1782,12 @@ CBB1731320F041F4007FCAE9 /* SCMiscUtilities.m */, CB81AA3825B7D152006956F7 /* SCHelperToolUtilities.h */, CB81AA3925B7D152006956F7 /* SCHelperToolUtilities.m */, + 7780059D90429CAD9D925F31 /* SCBlockClock.h */, + D483750E234BD131B4D3D705 /* SCBlockClock.m */, + A0ABF5538520738322E1D87F /* SCTrustedTime.h */, + 7DEFB5DF64C2C167C77F1936 /* SCTrustedTime.m */, + 3310B533DAE070805ECF5C66 /* SCPinnedHosts.h */, + 5B7620E0568062C60FE36D3B /* SCPinnedHosts.m */, ); path = Utility; sourceTree = ""; @@ -2913,7 +2943,7 @@ CBDFFF4624A044C900622CEE /* Copy Daemon Launch Service */, CB5E5FF81C3A5FD10038F331 /* ShellScript */, CB81AB2725B7EFA4006956F7 /* Embed Frameworks */, - BED0609C15A860C955A3552D /* [CP] Embed Pods Frameworks */, + 18074E0F50576891796754E7 /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -2934,7 +2964,7 @@ CB0EEF5920FD8CE00024D27B /* Sources */, CB0EEF5A20FD8CE00024D27B /* Frameworks */, CB0EEF5B20FD8CE00024D27B /* Resources */, - ED887BF54A55A00F42A6FD9C /* [CP] Embed Pods Frameworks */, + A90E36D275FE67D6D7D9178A /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -2973,7 +3003,6 @@ CB9C80F419CFB79700CDCAE1 /* Frameworks */, CB9C80F519CFB79700CDCAE1 /* Resources */, CB9C813119CFBBD300CDCAE1 /* Copy Helper Tools */, - AF328C5935C794FB31FCD1B5 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -3083,7 +3112,7 @@ Base, da, ); - mainGroup = 29B97314FDCFA39411CA2CEA /* SelfControl */; + mainGroup = 29B97314FDCFA39411CA2CEA; productRefGroup = 19C28FACFE9D520D11CA2CBB /* Products */; projectDirPath = ""; projectReferences = ( @@ -3426,6 +3455,80 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ + 18074E0F50576891796754E7 /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-SelfControl/Pods-SelfControl-resources.sh", + "${PODS_ROOT}/FormatterKit/FormatterKit/FormatterKit.bundle", + "${PODS_ROOT}/LetsMove/Base.lproj", + "${PODS_ROOT}/LetsMove/ca.lproj", + "${PODS_ROOT}/LetsMove/cs.lproj", + "${PODS_ROOT}/LetsMove/da.lproj", + "${PODS_ROOT}/LetsMove/de.lproj", + "${PODS_ROOT}/LetsMove/el.lproj", + "${PODS_ROOT}/LetsMove/en.lproj", + "${PODS_ROOT}/LetsMove/es.lproj", + "${PODS_ROOT}/LetsMove/fr.lproj", + "${PODS_ROOT}/LetsMove/hu.lproj", + "${PODS_ROOT}/LetsMove/it.lproj", + "${PODS_ROOT}/LetsMove/ja.lproj", + "${PODS_ROOT}/LetsMove/ko.lproj", + "${PODS_ROOT}/LetsMove/mk.lproj", + "${PODS_ROOT}/LetsMove/nb.lproj", + "${PODS_ROOT}/LetsMove/nl.lproj", + "${PODS_ROOT}/LetsMove/pl.lproj", + "${PODS_ROOT}/LetsMove/pt.lproj", + "${PODS_ROOT}/LetsMove/pt_BR.lproj", + "${PODS_ROOT}/LetsMove/ru.lproj", + "${PODS_ROOT}/LetsMove/sk.lproj", + "${PODS_ROOT}/LetsMove/sr.lproj", + "${PODS_ROOT}/LetsMove/sv.lproj", + "${PODS_ROOT}/LetsMove/tr.lproj", + "${PODS_ROOT}/LetsMove/vi-VN.lproj", + "${PODS_ROOT}/LetsMove/zh_CN.lproj", + "${PODS_ROOT}/LetsMove/zh_TW.lproj", + "${BUILT_PRODUCTS_DIR}/MASPreferences/MASPreferences.framework/en.lproj/MASPreferencesWindow.nib", + ); + name = "[CP] Copy Pods Resources"; + outputPaths = ( + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FormatterKit.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Base.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ca.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/cs.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/da.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/de.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/el.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/en.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/es.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/fr.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/hu.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/it.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ja.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ko.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/mk.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/nb.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/nl.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/pl.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/pt.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/pt_BR.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ru.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/sk.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/sr.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/sv.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/tr.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/vi-VN.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/zh_CN.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/zh_TW.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/MASPreferencesWindow.nib", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-SelfControl/Pods-SelfControl-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; 2A27B423AFFABE56BC018570 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -3514,48 +3617,78 @@ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; - AF328C5935C794FB31FCD1B5 /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-SelfControl Killer/Pods-SelfControl Killer-frameworks.sh", - "${BUILT_PRODUCTS_DIR}/Sentry-framework/Sentry.framework", - ); - name = "[CP] Embed Pods Frameworks"; - outputPaths = ( - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Sentry.framework", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-SelfControl Killer/Pods-SelfControl Killer-frameworks.sh\"\n"; - showEnvVarsInLog = 0; - }; - BED0609C15A860C955A3552D /* [CP] Embed Pods Frameworks */ = { + A90E36D275FE67D6D7D9178A /* [CP] Copy Pods Resources */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-SelfControl/Pods-SelfControl-frameworks.sh", - "${BUILT_PRODUCTS_DIR}/FormatterKit/FormatterKit.framework", - "${BUILT_PRODUCTS_DIR}/LetsMove/LetsMove.framework", - "${BUILT_PRODUCTS_DIR}/MASPreferences/MASPreferences.framework", - "${BUILT_PRODUCTS_DIR}/Sentry-framework/Sentry.framework", - "${BUILT_PRODUCTS_DIR}/TransformerKit/TransformerKit.framework", - ); - name = "[CP] Embed Pods Frameworks"; + "${PODS_ROOT}/Target Support Files/Pods-SelfControl-SelfControlTests/Pods-SelfControl-SelfControlTests-resources.sh", + "${PODS_ROOT}/FormatterKit/FormatterKit/FormatterKit.bundle", + "${PODS_ROOT}/LetsMove/Base.lproj", + "${PODS_ROOT}/LetsMove/ca.lproj", + "${PODS_ROOT}/LetsMove/cs.lproj", + "${PODS_ROOT}/LetsMove/da.lproj", + "${PODS_ROOT}/LetsMove/de.lproj", + "${PODS_ROOT}/LetsMove/el.lproj", + "${PODS_ROOT}/LetsMove/en.lproj", + "${PODS_ROOT}/LetsMove/es.lproj", + "${PODS_ROOT}/LetsMove/fr.lproj", + "${PODS_ROOT}/LetsMove/hu.lproj", + "${PODS_ROOT}/LetsMove/it.lproj", + "${PODS_ROOT}/LetsMove/ja.lproj", + "${PODS_ROOT}/LetsMove/ko.lproj", + "${PODS_ROOT}/LetsMove/mk.lproj", + "${PODS_ROOT}/LetsMove/nb.lproj", + "${PODS_ROOT}/LetsMove/nl.lproj", + "${PODS_ROOT}/LetsMove/pl.lproj", + "${PODS_ROOT}/LetsMove/pt.lproj", + "${PODS_ROOT}/LetsMove/pt_BR.lproj", + "${PODS_ROOT}/LetsMove/ru.lproj", + "${PODS_ROOT}/LetsMove/sk.lproj", + "${PODS_ROOT}/LetsMove/sr.lproj", + "${PODS_ROOT}/LetsMove/sv.lproj", + "${PODS_ROOT}/LetsMove/tr.lproj", + "${PODS_ROOT}/LetsMove/vi-VN.lproj", + "${PODS_ROOT}/LetsMove/zh_CN.lproj", + "${PODS_ROOT}/LetsMove/zh_TW.lproj", + "${BUILT_PRODUCTS_DIR}/MASPreferences/MASPreferences.framework/en.lproj/MASPreferencesWindow.nib", + ); + name = "[CP] Copy Pods Resources"; outputPaths = ( - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FormatterKit.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/LetsMove.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/MASPreferences.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Sentry.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/TransformerKit.framework", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FormatterKit.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Base.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ca.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/cs.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/da.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/de.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/el.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/en.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/es.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/fr.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/hu.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/it.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ja.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ko.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/mk.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/nb.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/nl.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/pl.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/pt.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/pt_BR.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ru.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/sk.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/sr.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/sv.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/tr.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/vi-VN.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/zh_CN.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/zh_TW.lproj", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/MASPreferencesWindow.nib", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-SelfControl/Pods-SelfControl-frameworks.sh\"\n"; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-SelfControl-SelfControlTests/Pods-SelfControl-SelfControlTests-resources.sh\"\n"; showEnvVarsInLog = 0; }; C4455A3F0260679D1BD5962E /* [CP] Check Pods Manifest.lock */ = { @@ -3666,32 +3799,6 @@ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; - ED887BF54A55A00F42A6FD9C /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-SelfControl-SelfControlTests/Pods-SelfControl-SelfControlTests-frameworks.sh", - "${BUILT_PRODUCTS_DIR}/FormatterKit/FormatterKit.framework", - "${BUILT_PRODUCTS_DIR}/LetsMove/LetsMove.framework", - "${BUILT_PRODUCTS_DIR}/MASPreferences/MASPreferences.framework", - "${BUILT_PRODUCTS_DIR}/Sentry-framework/Sentry.framework", - "${BUILT_PRODUCTS_DIR}/TransformerKit/TransformerKit.framework", - ); - name = "[CP] Embed Pods Frameworks"; - outputPaths = ( - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FormatterKit.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/LetsMove.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/MASPreferences.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Sentry.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/TransformerKit.framework", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-SelfControl-SelfControlTests/Pods-SelfControl-SelfControlTests-frameworks.sh\"\n"; - showEnvVarsInLog = 0; - }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -3727,6 +3834,9 @@ CB953114262BC64F000C8309 /* SCDurationSlider.m in Sources */, CBF3B574217BADD7006D5F52 /* SCSettings.m in Sources */, CB25806616C237F10059C99A /* NSString+IPAddress.m in Sources */, + EC65D5055C5CDFCECDB7FE91 /* SCBlockClock.m in Sources */, + A2EE8CF4722D0E9AF10DFFCB /* SCTrustedTime.m in Sources */, + 3D1962F9249CDADACC7209B6 /* SCPinnedHosts.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -3751,6 +3861,11 @@ CB114284222CD4F0004B7868 /* SCSettings.m in Sources */, CB0EEF7820FE49030024D27B /* SCUtilityTests.m in Sources */, CB81A94D25B7B5B6006956F7 /* SCMigrationUtilities.m in Sources */, + D53DB30311254599F589F6E9 /* SCBlockClock.m in Sources */, + 4E79FF9C6273791588F4AAE7 /* SCBlockClockTests.m in Sources */, + 53A83D6C78E6B28B265DB92A /* SCTrustedTime.m in Sources */, + C9CFA33CD08F20F878270FBF /* SCPinnedHosts.m in Sources */, + 0B8DE13F1AC76A3492243EF7 /* SCTrustedTimeTests.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -3784,6 +3899,9 @@ CB69C4EF25A3FD8A0030CFCD /* SCXPCAuthorization.m in Sources */, CB62FC4324B1329500ADBC40 /* PacketFilter.m in Sources */, CB62FC4224B1329200ADBC40 /* BlockManager.m in Sources */, + 3B32075B954DBAA7CDF5BE74 /* SCBlockClock.m in Sources */, + BA690D1AD4806A4CEF2922DB /* SCTrustedTime.m in Sources */, + 814B43720F59039AD312D69F /* SCPinnedHosts.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -3792,6 +3910,7 @@ buildActionMask = 2147483647; files = ( CB81A9D225B7C269006956F7 /* SCBlockUtilities.m in Sources */, + 85BDD37D005DD18B0FEF9609 /* SCBlockClock.m in Sources */, CB9C80FF19CFB79700CDCAE1 /* AppDelegate.m in Sources */, CB9C80FC19CFB79700CDCAE1 /* main.m in Sources */, CB5DFCBA2251DD1F0084CEC2 /* SCConstants.m in Sources */, @@ -3827,6 +3946,7 @@ CB1CA64F25ABA5BB0084A551 /* SCXPCClient.m in Sources */, CB114283222CCF19004B7868 /* SCSettings.m in Sources */, CB81A9D325B7C269006956F7 /* SCBlockUtilities.m in Sources */, + D26FC97A4313152D82B52636 /* SCBlockClock.m in Sources */, CB9C812319CFBB4400CDCAE1 /* LaunchctlHelper.m in Sources */, CB1CA64D25ABA5BB0084A551 /* SCXPCAuthorization.m in Sources */, CBC1F4B826070358008E3FA8 /* SCFileWatcher.m in Sources */, @@ -3850,6 +3970,7 @@ CBADC27F25B22BC7000EE5BB /* SCSentry.m in Sources */, CB1CA64E25ABA5BB0084A551 /* SCXPCAuthorization.m in Sources */, CB81A9D125B7C269006956F7 /* SCBlockUtilities.m in Sources */, + 1F62DFC8BCDB5A0494F70778 /* SCBlockClock.m in Sources */, CBB67D5C25D6165B006E4BC9 /* NSArray+XPMArgumentsNormalizer.m in Sources */, CB5888E225F60DC300B5C64D /* HostFileBlockerSet.m in Sources */, CBB67D5D25D6165B006E4BC9 /* NSDictionary+RubyDescription.m in Sources */, @@ -4532,9 +4653,7 @@ CREATE_INFOPLIST_SECTION_IN_BINARY = YES; CURRENT_PROJECT_VERSION = 410; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - FRAMEWORK_SEARCH_PATHS = ( - "$(PLATFORM_DIR)/Developer/Library/Frameworks\n\n$(PLATFORM_DIR)/Developer/Library/Frameworks\n\n", - ); + FRAMEWORK_SEARCH_PATHS = "$(PLATFORM_DIR)/Developer/Library/Frameworks\n\n$(PLATFORM_DIR)/Developer/Library/Frameworks\n\n"; GCC_DYNAMIC_NO_PIC = NO; GCC_MODEL_TUNING = G5; GCC_NO_COMMON_BLOCKS = NO; @@ -4567,9 +4686,7 @@ CREATE_INFOPLIST_SECTION_IN_BINARY = YES; CURRENT_PROJECT_VERSION = 410; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - FRAMEWORK_SEARCH_PATHS = ( - "$(PLATFORM_DIR)/Developer/Library/Frameworks\n\n$(PLATFORM_DIR)/Developer/Library/Frameworks\n\n", - ); + FRAMEWORK_SEARCH_PATHS = "$(PLATFORM_DIR)/Developer/Library/Frameworks\n\n$(PLATFORM_DIR)/Developer/Library/Frameworks\n\n"; GCC_MODEL_TUNING = G5; GCC_NO_COMMON_BLOCKS = NO; GCC_PRECOMPILE_PREFIX_HEADER = YES; diff --git a/SelfControlTests/SCBlockClockTests.m b/SelfControlTests/SCBlockClockTests.m new file mode 100644 index 00000000..cc933c91 --- /dev/null +++ b/SelfControlTests/SCBlockClockTests.m @@ -0,0 +1,163 @@ +#import +#import "SCBlockClock.h" +#import "SCSettings.h" + +@interface SCBlockClockTests : XCTestCase +@end + +@implementation SCBlockClockTests + +- (void)setUp { + [super setUp]; + [SCSettings sharedSettings].readOnly = NO; + [[SCSettings sharedSettings] setValue: nil forKey: @"BlockTimekeeping"]; +} + +- (void)tearDown { + [SCBlockClock setBootSessionUUIDOverrideForTesting: nil]; + [super tearDown]; +} + +- (void)testRecordBlockStartPopulatesTimekeepingDict { + NSDate* before = [NSDate date]; + [SCBlockClock recordBlockStartWithDuration: 600]; // 10 minutes + NSDate* after = [NSDate date]; + + NSDictionary* tk = [[SCSettings sharedSettings] valueForKey: @"BlockTimekeeping"]; + XCTAssertNotNil(tk); + + // Duration + initial elapsed + XCTAssertEqualWithAccuracy([tk[@"blockDurationSeconds"] doubleValue], 600.0, 0.001); + XCTAssertEqualWithAccuracy([tk[@"elapsedSecondsAccumulated"] doubleValue], 0.0, 0.001); + + // Wall-clock fields + NSDate* startWall = tk[@"blockStartWallClock"]; + NSDate* lastWall = tk[@"lastCheckpointWallClock"]; + XCTAssertNotNil(startWall); + XCTAssertNotNil(lastWall); + // Start wall-clock is now (within the time the call took) + XCTAssertGreaterThanOrEqual([startWall timeIntervalSinceDate: before], -0.001); + XCTAssertLessThanOrEqual([startWall timeIntervalSinceDate: after], 0.001); + // Last checkpoint wall-clock equals start wall-clock at t=0 + XCTAssertEqualWithAccuracy([lastWall timeIntervalSinceDate: startWall], 0.0, 0.001); + + // Continuous-time fields + XCTAssertNotNil(tk[@"blockStartContinuousTime"]); + XCTAssertNotNil(tk[@"lastCheckpointContinuous"]); + // Last checkpoint continuous equals start continuous at t=0 + XCTAssertEqualObjects(tk[@"blockStartContinuousTime"], tk[@"lastCheckpointContinuous"]); + + // Boot session ID present + XCTAssertNotNil(tk[@"bootSessionUUID"]); + XCTAssertGreaterThan([tk[@"bootSessionUUID"] length], 0); +} + +- (void)testRecordBlockStartReplacesPriorDict { + [SCBlockClock recordBlockStartWithDuration: 60]; + NSDictionary* first = [[[SCSettings sharedSettings] valueForKey: @"BlockTimekeeping"] copy]; + + [NSThread sleepForTimeInterval: 0.05]; + [SCBlockClock recordBlockStartWithDuration: 120]; + NSDictionary* second = [[SCSettings sharedSettings] valueForKey: @"BlockTimekeeping"]; + + XCTAssertEqualWithAccuracy([second[@"blockDurationSeconds"] doubleValue], 120.0, 0.001); + // The second record should have a later start wall-clock than the first + XCTAssertGreaterThan([second[@"blockStartWallClock"] timeIntervalSinceDate: first[@"blockStartWallClock"]], 0.0); +} + +- (void)testTickAdvancesElapsedByTrustedDelta { + [SCBlockClock recordBlockStartWithDuration: 600]; + [NSThread sleepForTimeInterval: 2.0]; + [SCBlockClock tickCheckpoint]; + + NSTimeInterval elapsed = [SCBlockClock elapsedSecondsForCurrentBlock]; + XCTAssertGreaterThan(elapsed, 1.5); + XCTAssertLessThan(elapsed, 3.0); +} + +- (void)testElapsedIncludesInFlightSinceLastCheckpoint { + [SCBlockClock recordBlockStartWithDuration: 600]; + NSTimeInterval immediate = [SCBlockClock elapsedSecondsForCurrentBlock]; + XCTAssertLessThan(immediate, 0.5); + [NSThread sleepForTimeInterval: 1.0]; + NSTimeInterval later = [SCBlockClock elapsedSecondsForCurrentBlock]; + XCTAssertGreaterThan(later, 0.8); +} + +- (void)testBlockDurationHasElapsedFalseInitiallyTrueAfterDuration { + [SCBlockClock recordBlockStartWithDuration: 1]; + XCTAssertFalse([SCBlockClock blockDurationHasElapsed]); + [NSThread sleepForTimeInterval: 1.2]; + [SCBlockClock tickCheckpoint]; + XCTAssertTrue([SCBlockClock blockDurationHasElapsed]); +} + +- (void)testTickClampsAgainstForwardWallClockJump { + [SCBlockClock recordBlockStartWithDuration: 600]; + [NSThread sleepForTimeInterval: 0.5]; + + // Simulate sudo date +1hour by pushing lastCheckpointWallClock 1 hour into the past. + // From tickCheckpoint's POV, deltaWall will be ~1 hour but deltaCont will be ~0.5s. + NSMutableDictionary* tk = [[[SCSettings sharedSettings] valueForKey: @"BlockTimekeeping"] mutableCopy]; + tk[@"lastCheckpointWallClock"] = [NSDate dateWithTimeIntervalSinceNow: -3600.0]; + [[SCSettings sharedSettings] setValue: tk forKey: @"BlockTimekeeping"]; + + [SCBlockClock tickCheckpoint]; + NSTimeInterval elapsed = [SCBlockClock elapsedSecondsForCurrentBlock]; + XCTAssertLessThan(elapsed, 5.0); // not 1 hour — clamped to monotonic delta +} + +- (void)testRebootCreditsWallClockGap { + [SCBlockClock setBootSessionUUIDOverrideForTesting: @"BOOT_A"]; + [SCBlockClock recordBlockStartWithDuration: 600]; + [NSThread sleepForTimeInterval: 0.5]; + [SCBlockClock tickCheckpoint]; // accumulate ~0.5 s under BOOT_A + + // Simulate reboot: bump boot UUID and force the last checkpoint into the past. + [SCBlockClock setBootSessionUUIDOverrideForTesting: @"BOOT_B"]; + NSMutableDictionary* tk = [[[SCSettings sharedSettings] valueForKey: @"BlockTimekeeping"] mutableCopy]; + tk[@"lastCheckpointWallClock"] = [NSDate dateWithTimeIntervalSinceNow: -60.0]; + [[SCSettings sharedSettings] setValue: tk forKey: @"BlockTimekeeping"]; + + [SCBlockClock tickCheckpoint]; // should credit ~60 s of reboot gap + NSTimeInterval elapsed = [SCBlockClock elapsedSecondsForCurrentBlock]; + XCTAssertGreaterThan(elapsed, 55.0); + XCTAssertLessThan(elapsed, 65.0); +} + +- (void)testRebootWithBackwardWallClockCreditsZero { + [SCBlockClock setBootSessionUUIDOverrideForTesting: @"BOOT_A"]; + [SCBlockClock recordBlockStartWithDuration: 600]; + [NSThread sleepForTimeInterval: 0.5]; + [SCBlockClock tickCheckpoint]; + + NSTimeInterval accumBeforeReboot = [SCBlockClock elapsedSecondsForCurrentBlock]; + + [SCBlockClock setBootSessionUUIDOverrideForTesting: @"BOOT_B"]; + NSMutableDictionary* tk = [[[SCSettings sharedSettings] valueForKey: @"BlockTimekeeping"] mutableCopy]; + tk[@"lastCheckpointWallClock"] = [NSDate dateWithTimeIntervalSinceNow: +60.0]; // future + [[SCSettings sharedSettings] setValue: tk forKey: @"BlockTimekeeping"]; + + [SCBlockClock tickCheckpoint]; + NSTimeInterval elapsedAfter = [SCBlockClock elapsedSecondsForCurrentBlock]; + XCTAssertEqualWithAccuracy(elapsedAfter, accumBeforeReboot, 1.0); +} + +- (void)testBackwardWallClockCreditsAtMostMonotonic { + [SCBlockClock recordBlockStartWithDuration: 600]; + [NSThread sleepForTimeInterval: 0.5]; + [SCBlockClock tickCheckpoint]; + NSTimeInterval before = [SCBlockClock elapsedSecondsForCurrentBlock]; + + // Move lastCheckpointWallClock 1 hour into the future to simulate + // `sudo date -1hour` having moved "now" backward relative to it. + NSMutableDictionary* tk = [[[SCSettings sharedSettings] valueForKey: @"BlockTimekeeping"] mutableCopy]; + tk[@"lastCheckpointWallClock"] = [NSDate dateWithTimeIntervalSinceNow: +3600.0]; + [[SCSettings sharedSettings] setValue: tk forKey: @"BlockTimekeeping"]; + + [SCBlockClock tickCheckpoint]; + NSTimeInterval after = [SCBlockClock elapsedSecondsForCurrentBlock]; + XCTAssertEqualWithAccuracy(after, before, 1.0); // negative deltaWall → zero credit +} + +@end diff --git a/SelfControlTests/SCTrustedTimeTests.m b/SelfControlTests/SCTrustedTimeTests.m new file mode 100644 index 00000000..8cbe7ba6 --- /dev/null +++ b/SelfControlTests/SCTrustedTimeTests.m @@ -0,0 +1,79 @@ +#import +#import "SCTrustedTime.h" +#import "SCPinnedHosts.h" + +@interface SCTrustedTimeTests : XCTestCase +@end + +@implementation SCTrustedTimeTests + +- (void)testParseValidIMFFixdate { + NSDate* d = [SCTrustedTime parseHTTPDateHeader: @"Tue, 14 May 2026 12:00:00 GMT"]; + XCTAssertNotNil(d); + NSDateComponents* c = [[NSCalendar calendarWithIdentifier: NSCalendarIdentifierGregorian] + componentsInTimeZone: [NSTimeZone timeZoneWithAbbreviation: @"GMT"] + fromDate: d]; + XCTAssertEqual(c.year, 2026); + XCTAssertEqual(c.month, 5); + XCTAssertEqual(c.day, 14); + XCTAssertEqual(c.hour, 12); +} + +- (void)testParseGarbageReturnsNil { + XCTAssertNil([SCTrustedTime parseHTTPDateHeader: @"not a date"]); + XCTAssertNil([SCTrustedTime parseHTTPDateHeader: @""]); + XCTAssertNil([SCTrustedTime parseHTTPDateHeader: nil]); +} + +- (void)testFetchFromAppleWithCorrectPinReturnsDate { + XCTestExpectation* exp = [self expectationWithDescription: @"apple"]; + NSDictionary* apple = [SCPinnedHosts hosts][0]; // assumes apple is index 0 + [SCTrustedTime fetchTimeFromHost: apple[@"host"] + expectedSPKI: apple[@"spki"] + timeout: 10.0 + completion:^(NSDate* d, NSError* e) { + XCTAssertNil(e); + XCTAssertNotNil(d); + XCTAssertLessThan(ABS([d timeIntervalSinceNow]), 120.0); + [exp fulfill]; + }]; + [self waitForExpectations: @[exp] timeout: 15.0]; +} + +- (void)testFetchWithWrongPinFails { + XCTestExpectation* exp = [self expectationWithDescription: @"badpin"]; + [SCTrustedTime fetchTimeFromHost: @"www.apple.com" + expectedSPKI: @"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + timeout: 10.0 + completion:^(NSDate* d, NSError* e) { + XCTAssertNotNil(e); + XCTAssertNil(d); + [exp fulfill]; + }]; + [self waitForExpectations: @[exp] timeout: 15.0]; +} + +- (void)testVerifyAfterPastThresholdSucceeds { + XCTestExpectation* exp = [self expectationWithDescription: @"quorum"]; + NSDate* past = [NSDate dateWithTimeIntervalSinceNow: -3600]; + [SCTrustedTime verifyTimeIsAfter: past + completion:^(BOOL ok, NSDate* med, NSError* err) { + XCTAssertTrue(ok); + XCTAssertNotNil(med); + [exp fulfill]; + }]; + [self waitForExpectations: @[exp] timeout: 25.0]; +} + +- (void)testVerifyAfterFutureThresholdFails { + XCTestExpectation* exp = [self expectationWithDescription: @"future"]; + NSDate* future = [NSDate dateWithTimeIntervalSinceNow: 86400 * 365 * 10]; + [SCTrustedTime verifyTimeIsAfter: future + completion:^(BOOL ok, NSDate* med, NSError* err) { + XCTAssertFalse(ok); + [exp fulfill]; + }]; + [self waitForExpectations: @[exp] timeout: 25.0]; +} + +@end diff --git a/SelfControlTests/SCUtilityTests.m b/SelfControlTests/SCUtilityTests.m index a92caa7b..3493acf3 100644 --- a/SelfControlTests/SCUtilityTests.m +++ b/SelfControlTests/SCUtilityTests.m @@ -10,6 +10,7 @@ #import "SCSentry.h" #import "SCErr.h" #import "SCSettings.h" +#import "SCBlockClock.h" @interface SCUtilityTests : XCTestCase @@ -164,6 +165,26 @@ - (void) testModernBlockDetection { XCTAssert([SCBlockUtilities currentBlockIsExpired]); } +- (void)testTrulyExpiredRequiresBothSignals { + SCSettings* s = [SCSettings sharedSettings]; + [s setValue: @YES forKey: @"BlockIsRunning"]; + [s setValue: [NSDate dateWithTimeIntervalSinceNow: -10] forKey: @"BlockEndDate"]; // wall says expired + [SCBlockClock recordBlockStartWithDuration: 600]; // monotonic says NOT expired + + XCTAssertTrue([SCBlockUtilities currentBlockIsExpired]); + XCTAssertFalse([SCBlockUtilities currentBlockIsTrulyExpired]); + + // Now flip the monotonic side to also report expired. + NSMutableDictionary* tk = [[s valueForKey: @"BlockTimekeeping"] mutableCopy]; + tk[@"elapsedSecondsAccumulated"] = @(99999); + [s setValue: tk forKey: @"BlockTimekeeping"]; + XCTAssertTrue([SCBlockUtilities currentBlockIsTrulyExpired]); + + // Cleanup so this test doesn't leak state to other tests + [SCBlockUtilities removeBlockFromSettings]; + [s setValue: nil forKey: @"BlockTimekeeping"]; +} + - (void) testLegacyBlockDetection { // test blockIsRunningInLegacyDictionary // the block is "running" even if it's expired, since it hasn't been removed diff --git a/TimerWindowController.m b/TimerWindowController.m index 03fcb6c2..3a6182ab 100755 --- a/TimerWindowController.m +++ b/TimerWindowController.m @@ -24,10 +24,16 @@ #import "TimerWindowController.h" #import "SCUIUtilities.h" +#import "SCXPCClient.h" +#import "SCBlockUtilities.h" +#import "SCBlockClock.h" @interface TimerWindowController () @property(nonatomic, readonly) AppController* appController; +@property(nonatomic, strong) SCXPCClient* gateXPCClient; +@property(nonatomic, assign) BOOL gateQueryInFlight; +@property(nonatomic, assign) BOOL waitingForNetworkUnlock; @end @@ -123,6 +129,8 @@ - (void)blockEnded { [timerUpdater_ invalidate]; timerUpdater_ = nil; + self.waitingForNetworkUnlock = NO; + [timerLabel_ setStringValue: NSLocalizedString(@"Block not active", @"block not active string")]; [timerLabel_ setFont: [[NSFontManager sharedFontManager] convertFont: [timerLabel_ font] @@ -148,13 +156,56 @@ - (void)updateTimerDisplay:(NSTimer*)timer { waitUntilDone:NO]; NSString* finishingString = NSLocalizedString(@"Finishing", @"String shown when waiting for finished block to clear"); - int numSeconds = (int) [blockEndingDate_ timeIntervalSinceNow]; + NSString* waitingForInternetString = [NSString stringWithFormat: @"%@\n%@", + NSLocalizedString(@"Connect to the internet to finish your block.", + @"Shown when block end time has passed but trusted-time check has not yet succeeded."), + NSLocalizedString(@"SelfControl checks the time with a trusted server. Once online, this finishes automatically.", + @"Second line of the waiting-for-internet message.")]; + + // For modern blocks, drive the timer from SCBlockClock (tamper-resistant elapsed time) + // so a `sudo date` jump cannot trip the strike counter / expose the manual-stop button. + // Legacy blocks predate SCBlockClock and keep the wall-clock path. + BOOL useBlockClock = [SCBlockUtilities modernBlockIsRunning] && [SCBlockClock blockDurationSeconds] > 0; + BOOL blockElapsedButStillRunning; + int numSeconds; + if (useBlockClock) { + numSeconds = (int)[SCBlockClock remainingSecondsForCurrentBlock]; + blockElapsedButStillRunning = [SCBlockClock blockDurationHasElapsed]; + } else { + numSeconds = (int)[blockEndingDate_ timeIntervalSinceNow]; + blockElapsedButStillRunning = (numSeconds < 0 && [SCBlockUtilities modernBlockIsRunning]); + } int numHours; int numMinutes; + // Block elapsed but not yet cleared: the daemon may be waiting on trusted-time + // network verification. Query the gate so we can surface that state to the user. + if (blockElapsedButStillRunning) { + [self queryUnlockGateState]; + } else if (self.waitingForNetworkUnlock) { + // no longer past end (clock restored, or block cleared) - clear waiting state + self.waitingForNetworkUnlock = NO; + } + + // If the daemon previously reported it was waiting for network verification, show that message + // instead of "Finishing". We re-poll periodically (see queryUnlockGateState) to refresh this. + if (blockElapsedButStillRunning && self.waitingForNetworkUnlock) { + if (![timerLabel_.stringValue isEqualToString: waitingForInternetString]) { + [[NSApp dockTile] setBadgeLabel: nil]; + [timerLabel_ setStringValue: waitingForInternetString]; + [timerLabel_ setFont: [[NSFontManager sharedFontManager] + convertFont: [timerLabel_ font] + toSize: 14]]; + [timerLabel_ sizeToFit]; + [timerLabel_ setFrame: NSRectFromCGRect(CGRectMake(0, timerLabel_.frame.origin.y, self.window.frame.size.width, timerLabel_.frame.size.height))]; + } + [self resetStrikes]; + return; + } + // if we're already showing "Finishing", but the block timer isn't clearing, // keep track of that, so we can take drastic measures if necessary. - if(numSeconds < 0 && [timerLabel_.stringValue isEqualToString: finishingString]) { + if(blockElapsedButStillRunning && [timerLabel_.stringValue isEqualToString: finishingString]) { [[NSApp dockTile] setBadgeLabel: nil]; // This increments the strike counter. After four strikes of the timer being @@ -318,6 +369,24 @@ - (void)resetStrikes { numStrikes = 0; } +// Asynchronously ask the daemon whether it's waiting on trusted-time network verification. +// We coalesce in-flight requests so we don't pile up XPC calls when the timer ticks every second. +- (void)queryUnlockGateState { + if (self.gateQueryInFlight) return; + self.gateQueryInFlight = YES; + + if (self.gateXPCClient == nil) { + self.gateXPCClient = [SCXPCClient new]; + } + + [self.gateXPCClient getBlockUnlockGateStateWithReply:^(BOOL waiting, NSDate* lastAttemptAt, NSString* errorReason) { + dispatch_async(dispatch_get_main_queue(), ^{ + self.waitingForNetworkUnlock = waiting; + self.gateQueryInFlight = NO; + }); + }]; +} + - (IBAction)killBlock:(id)sender { AuthorizationRef authorizationRef; char* helperToolPath = [self selfControlKillerHelperToolPathUTF8String]; diff --git a/docs/superpowers/plans/2026-05-14-tamper-resistant-timing.md b/docs/superpowers/plans/2026-05-14-tamper-resistant-timing.md new file mode 100644 index 00000000..f253273a --- /dev/null +++ b/docs/superpowers/plans/2026-05-14-tamper-resistant-timing.md @@ -0,0 +1,1405 @@ +# Tamper-Resistant Block Timing Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Stop the `sudo date` bypass (and similar casual tampering) so a block cannot end early. + +**Architecture:** Three-layer time gate. (1) `mach_continuous_time()` monotonic counter, (2) a per-30-second on-disk checkpoint that survives reboot, (3) a pinned HTTPS time check (Apple/Google/Cloudflare `Date` header) at unlock. The block can only end when all three agree. UI tells the user when we are waiting on the internet check. + +**Tech Stack:** Objective-C / macOS, Foundation `URLSession` for HTTPS, `XCTest`, settings persisted via existing `SCSettings` plist at `/usr/local/etc/`. + +**Spec:** `docs/superpowers/specs/2026-05-14-tamper-resistant-timing-design.md` + +--- + +## File Structure + +**New files:** +- `Common/Utility/SCBlockClock.h` / `.m` — monotonic counter math + checkpoint persistence + reboot handling. One responsibility: "how much real time has elapsed since block start?" +- `Common/Utility/SCTrustedTime.h` / `.m` — pinned HTTPS time check. One responsibility: "what time does the trusted internet say it is?" +- `Common/Utility/SCPinnedHosts.h` — baked-in `(host, SPKI-SHA256)` tuples. +- `SelfControlTests/SCBlockClockTests.m` — XCTest for the clock. +- `SelfControlTests/SCTrustedTimeTests.m` — XCTest for the pinning + parser. + +**Modified files:** +- `Common/SCSettings.m` — register defaults for new `BlockTimekeeping` dictionary key. +- `Common/Utility/SCBlockUtilities.h` / `.m` — replace single-signal `currentBlockIsExpired` with a three-signal `currentBlockIsTrulyExpired` method that consults `SCBlockClock` + `BlockEndDate`. The old method stays for transitional callers; it now returns the *less strict* answer. +- `Daemon/SCDaemonBlockMethods.m` — `startBlock` records `[SCBlockClock recordBlockStart...]`; `checkupBlock` uses the new gate and the trusted-time check. +- `Daemon/SCDaemon.m` / `.h` — add `startCheckpointTimer` / `stopCheckpointTimer` (30 s) and an unlock-attempt backoff state. +- `Daemon/SCDaemonProtocol.h`, `Common/SCXPCClient.h` / `.m` — add `getBlockUnlockGateStateWithReply:` so the app UI can read whether we are waiting on the internet check. +- `TimerWindowController.m` — when the gate state reports `waitingForNetworkVerification == YES`, swap the timer label for the "waiting for internet" copy. + +**Why one file per piece:** the clock math and the network check have no overlap. Splitting them means each unit can be unit-tested in isolation, and a future change to (say) the pinning list does not need to touch the clock code. + +--- + +## Conventions + +- **Language:** Objective-C, ARC enabled, matching the rest of the codebase. +- **Tests:** `XCTest`, run via `xcodebuild test -workspace SelfControl.xcworkspace -scheme SelfControl -destination 'platform=macOS'` (the existing way). The test target is `SelfControlTests`. +- **Commits:** one per task. Conventional Commits style (`feat:`, `test:`, `refactor:`) matching prior repo history. +- **Adding files to Xcode:** every new source file must be added to the correct target in `SelfControl.xcodeproj`. Each task that creates a new file ends with a manual step: "Add the new file to the SelfControl target *and* the SelfControlTests target (if a test file) via Xcode's File → Add Files…, then commit `project.pbxproj`." + +--- + +## Task 1: SCBlockClock skeleton + record block start + +**Files:** +- Create: `Common/Utility/SCBlockClock.h` +- Create: `Common/Utility/SCBlockClock.m` +- Create: `SelfControlTests/SCBlockClockTests.m` + +**Goal:** establish the class. `+recordBlockStartWithDuration:` writes a fresh `BlockTimekeeping` dict into `SCSettings` containing the monotonic counter value, wall-clock start, boot UUID, and zero elapsed. + +- [ ] **Step 1: Write the failing test** + +Create `SelfControlTests/SCBlockClockTests.m`: + +```objc +#import +#import "SCBlockClock.h" +#import "SCSettings.h" + +@interface SCBlockClockTests : XCTestCase +@end + +@implementation SCBlockClockTests + +- (void)setUp { + [super setUp]; + [SCSettings sharedSettings].readOnly = NO; + [[SCSettings sharedSettings] setValue: nil forKey: @"BlockTimekeeping"]; +} + +- (void)testRecordBlockStartPopulatesTimekeepingDict { + [SCBlockClock recordBlockStartWithDuration: 600]; // 10 minutes + + NSDictionary* tk = [[SCSettings sharedSettings] valueForKey: @"BlockTimekeeping"]; + XCTAssertNotNil(tk); + XCTAssertEqualWithAccuracy([tk[@"blockDurationSeconds"] doubleValue], 600.0, 0.001); + XCTAssertNotNil(tk[@"blockStartWallClock"]); + XCTAssertNotNil(tk[@"blockStartContinuousTime"]); + XCTAssertNotNil(tk[@"bootSessionUUID"]); + XCTAssertEqualWithAccuracy([tk[@"elapsedSecondsAccumulated"] doubleValue], 0.0, 0.001); +} + +@end +``` + +- [ ] **Step 2: Run the test, confirm it fails to compile** + +Run: `xcodebuild test -workspace SelfControl.xcworkspace -scheme SelfControl -destination 'platform=macOS' -only-testing:SelfControlTests/SCBlockClockTests` + +Expected: build error — `SCBlockClock.h` not found. + +- [ ] **Step 3: Create the header** + +Create `Common/Utility/SCBlockClock.h`: + +```objc +#import + +NS_ASSUME_NONNULL_BEGIN + +/// Tamper-resistant block-elapsed-time accounting. +/// Combines mach_continuous_time() with a periodic on-disk checkpoint so that +/// changing the system clock with `sudo date` cannot end a block early. +@interface SCBlockClock : NSObject + +/// Called once at block start. Writes the BlockTimekeeping dictionary into SCSettings. ++ (void)recordBlockStartWithDuration:(NSTimeInterval)durationSeconds; + +/// Called every ~30 s by the daemon. Updates elapsedSecondsAccumulated and the +/// last-checkpoint values, using the smaller of the wall-clock delta and the +/// monotonic delta. A negative wall-clock delta credits zero. ++ (void)tickCheckpoint; + +/// Total real seconds elapsed since the block started. Returns 0 if no block. ++ (NSTimeInterval)elapsedSecondsForCurrentBlock; + +/// YES iff elapsedSecondsForCurrentBlock >= the recorded duration. ++ (BOOL)blockDurationHasElapsed; + +@end + +NS_ASSUME_NONNULL_END +``` + +- [ ] **Step 4: Create the minimal implementation** + +Create `Common/Utility/SCBlockClock.m`: + +```objc +#import "SCBlockClock.h" +#import "SCSettings.h" +#import +#import + +static NSString* const kTKKey = @"BlockTimekeeping"; + +@implementation SCBlockClock + ++ (NSString*)currentBootSessionUUID { + struct timeval boottime; + size_t size = sizeof(boottime); + if (sysctlbyname("kern.boottime", &boottime, &size, NULL, 0) != 0) { + return @"unknown"; + } + return [NSString stringWithFormat: @"%ld.%d", (long)boottime.tv_sec, boottime.tv_usec]; +} + ++ (uint64_t)continuousNanos { + return mach_continuous_time(); +} + ++ (void)recordBlockStartWithDuration:(NSTimeInterval)durationSeconds { + NSDate* now = [NSDate date]; + uint64_t cont = [self continuousNanos]; + NSDictionary* tk = @{ + @"blockStartWallClock": now, + @"blockStartContinuousTime": @(cont), + @"bootSessionUUID": [self currentBootSessionUUID], + @"blockDurationSeconds": @(durationSeconds), + @"elapsedSecondsAccumulated": @(0.0), + @"lastCheckpointWallClock": now, + @"lastCheckpointContinuous": @(cont), + }; + [[SCSettings sharedSettings] setValue: tk forKey: kTKKey]; +} + ++ (void)tickCheckpoint { /* implemented in Task 2 */ } ++ (NSTimeInterval)elapsedSecondsForCurrentBlock { return 0.0; /* Task 2 */ } ++ (BOOL)blockDurationHasElapsed { return NO; /* Task 2 */ } + +@end +``` + +- [ ] **Step 5: Add both new files to the Xcode targets** + +Open `SelfControl.xcworkspace` in Xcode. File → Add Files…: +- Add `Common/Utility/SCBlockClock.h` and `.m` to the **SelfControl** target *and* the **org.eyebeam.selfcontrold** (daemon) target. (Both targets need the class.) +- Add `SelfControlTests/SCBlockClockTests.m` to the **SelfControlTests** target only. + +- [ ] **Step 6: Run the test, confirm it passes** + +Run: `xcodebuild test -workspace SelfControl.xcworkspace -scheme SelfControl -destination 'platform=macOS' -only-testing:SelfControlTests/SCBlockClockTests/testRecordBlockStartPopulatesTimekeepingDict` +Expected: 1 test, 0 failures. + +- [ ] **Step 7: Commit** + +```bash +git add Common/Utility/SCBlockClock.h Common/Utility/SCBlockClock.m SelfControlTests/SCBlockClockTests.m SelfControl.xcodeproj/project.pbxproj +git commit -m "feat: add SCBlockClock with recordBlockStart" +``` + +--- + +## Task 2: Same-boot checkpoint accounting + +**Files:** +- Modify: `Common/Utility/SCBlockClock.m` +- Modify: `SelfControlTests/SCBlockClockTests.m` + +**Goal:** `tickCheckpoint` increments `elapsedSecondsAccumulated` by `min(deltaContinuous, max(0, deltaWall))`. `elapsedSecondsForCurrentBlock` returns the accumulated total plus the in-flight delta since the last checkpoint. + +- [ ] **Step 1: Write the failing test** + +Append to `SelfControlTests/SCBlockClockTests.m` inside `@implementation`: + +```objc +- (void)testTickAdvancesElapsedByTrustedDelta { + [SCBlockClock recordBlockStartWithDuration: 600]; + + // Simulate 2 seconds of real time. The simplest way: sleep. + [NSThread sleepForTimeInterval: 2.0]; + [SCBlockClock tickCheckpoint]; + + NSTimeInterval elapsed = [SCBlockClock elapsedSecondsForCurrentBlock]; + XCTAssertGreaterThan(elapsed, 1.5); + XCTAssertLessThan(elapsed, 3.0); +} + +- (void)testElapsedIncludesInFlightSinceLastCheckpoint { + [SCBlockClock recordBlockStartWithDuration: 600]; + NSTimeInterval immediate = [SCBlockClock elapsedSecondsForCurrentBlock]; + XCTAssertLessThan(immediate, 0.5); // no checkpoint yet, but barely any time + [NSThread sleepForTimeInterval: 1.0]; + NSTimeInterval later = [SCBlockClock elapsedSecondsForCurrentBlock]; + XCTAssertGreaterThan(later, 0.8); +} + +- (void)testBlockDurationHasElapsedFalseInitiallyTrueAfterDuration { + [SCBlockClock recordBlockStartWithDuration: 1]; // 1 second + XCTAssertFalse([SCBlockClock blockDurationHasElapsed]); + [NSThread sleepForTimeInterval: 1.2]; + [SCBlockClock tickCheckpoint]; + XCTAssertTrue([SCBlockClock blockDurationHasElapsed]); +} +``` + +- [ ] **Step 2: Run, confirm failures** + +Run the three tests. Expected: all fail (`tickCheckpoint` is a no-op, `elapsedSecondsForCurrentBlock` returns 0). + +- [ ] **Step 3: Replace the stubs in `SCBlockClock.m`** + +```objc ++ (NSDictionary*)readTK { + return [[SCSettings sharedSettings] valueForKey: kTKKey]; +} + ++ (void)writeTK:(NSDictionary*)tk { + [[SCSettings sharedSettings] setValue: tk forKey: kTKKey]; +} + ++ (NSTimeInterval)inFlightDeltaFromTK:(NSDictionary*)tk { + NSDate* lastWall = tk[@"lastCheckpointWallClock"]; + uint64_t lastCont = [tk[@"lastCheckpointContinuous"] unsignedLongLongValue]; + + NSTimeInterval deltaWall = [[NSDate date] timeIntervalSinceDate: lastWall]; + NSTimeInterval deltaCont = ([self continuousNanos] - lastCont) / 1e9; + + NSTimeInterval trustedDelta = MIN(deltaCont, MAX(0.0, deltaWall)); + if (trustedDelta < 0) trustedDelta = 0; + return trustedDelta; +} + ++ (void)tickCheckpoint { + NSDictionary* tk = [self readTK]; + if (tk == nil) return; + + // Same-boot guard: if the bootSessionUUID changed, the cross-boot path + // in Task 3 handles it. For now we assume same boot. + if (![tk[@"bootSessionUUID"] isEqualToString: [self currentBootSessionUUID]]) { + // handled in Task 3 + return; + } + + NSTimeInterval trustedDelta = [self inFlightDeltaFromTK: tk]; + NSTimeInterval newAccum = [tk[@"elapsedSecondsAccumulated"] doubleValue] + trustedDelta; + + NSMutableDictionary* updated = [tk mutableCopy]; + updated[@"elapsedSecondsAccumulated"] = @(newAccum); + updated[@"lastCheckpointWallClock"] = [NSDate date]; + updated[@"lastCheckpointContinuous"] = @([self continuousNanos]); + [self writeTK: updated]; +} + ++ (NSTimeInterval)elapsedSecondsForCurrentBlock { + NSDictionary* tk = [self readTK]; + if (tk == nil) return 0.0; + if (![tk[@"bootSessionUUID"] isEqualToString: [self currentBootSessionUUID]]) { + // cross-boot — wait for tickCheckpoint to reconcile (Task 3) + return [tk[@"elapsedSecondsAccumulated"] doubleValue]; + } + return [tk[@"elapsedSecondsAccumulated"] doubleValue] + [self inFlightDeltaFromTK: tk]; +} + ++ (BOOL)blockDurationHasElapsed { + NSDictionary* tk = [self readTK]; + if (tk == nil) return NO; + return [self elapsedSecondsForCurrentBlock] >= [tk[@"blockDurationSeconds"] doubleValue]; +} +``` + +- [ ] **Step 4: Run, confirm pass** + +Run all `SCBlockClockTests`. Expected: 4/4 pass. + +- [ ] **Step 5: Commit** + +```bash +git add Common/Utility/SCBlockClock.m SelfControlTests/SCBlockClockTests.m +git commit -m "feat(blockclock): same-boot checkpoint accounting" +``` + +--- + +## Task 3: Reboot handling + +**Files:** +- Modify: `Common/Utility/SCBlockClock.h` / `.m` +- Modify: `SelfControlTests/SCBlockClockTests.m` + +**Goal:** Detect a boot-session-UUID change. On the first tick after a reboot, credit `max(0, now - lastCheckpointWallClock)` as elapsed time (treating reboot gap as honest wall-clock time), then reset the boot session. + +We cannot literally reboot in a unit test, so we add a *test-only* hook to override the boot UUID. + +- [ ] **Step 1: Add a testing hook to the header** + +Append to `Common/Utility/SCBlockClock.h` before `@end`: + +```objc +#ifdef DEBUG +/// Test-only override. Pass nil to clear. ++ (void)setBootSessionUUIDOverrideForTesting:(nullable NSString*)uuid; +#endif +``` + +- [ ] **Step 2: Implement the override in `SCBlockClock.m`** + +Add at file top (below imports): + +```objc +static NSString* sBootUUIDOverride = nil; +``` + +Modify `currentBootSessionUUID`: + +```objc ++ (NSString*)currentBootSessionUUID { + if (sBootUUIDOverride != nil) return sBootUUIDOverride; + struct timeval boottime; + size_t size = sizeof(boottime); + if (sysctlbyname("kern.boottime", &boottime, &size, NULL, 0) != 0) { + return @"unknown"; + } + return [NSString stringWithFormat: @"%ld.%d", (long)boottime.tv_sec, boottime.tv_usec]; +} + +#ifdef DEBUG ++ (void)setBootSessionUUIDOverrideForTesting:(NSString*)uuid { + sBootUUIDOverride = [uuid copy]; +} +#endif +``` + +- [ ] **Step 3: Write the failing test** + +Append to `SCBlockClockTests.m`: + +```objc +- (void)testRebootCreditsWallClockGap { + [SCBlockClock setBootSessionUUIDOverrideForTesting: @"BOOT_A"]; + [SCBlockClock recordBlockStartWithDuration: 600]; + [NSThread sleepForTimeInterval: 0.5]; + [SCBlockClock tickCheckpoint]; // accumulate ~0.5 s under BOOT_A + + // Simulate reboot: bump boot UUID, force wall clock forward by rewriting + // lastCheckpointWallClock to 60 s ago. + [SCBlockClock setBootSessionUUIDOverrideForTesting: @"BOOT_B"]; + NSMutableDictionary* tk = [[[SCSettings sharedSettings] valueForKey: @"BlockTimekeeping"] mutableCopy]; + tk[@"lastCheckpointWallClock"] = [NSDate dateWithTimeIntervalSinceNow: -60.0]; + [[SCSettings sharedSettings] setValue: tk forKey: @"BlockTimekeeping"]; + + [SCBlockClock tickCheckpoint]; // should credit ~60 s of reboot gap + NSTimeInterval elapsed = [SCBlockClock elapsedSecondsForCurrentBlock]; + XCTAssertGreaterThan(elapsed, 55.0); + XCTAssertLessThan(elapsed, 65.0); +} + +- (void)testRebootWithBackwardWallClockCreditsZero { + [SCBlockClock setBootSessionUUIDOverrideForTesting: @"BOOT_A"]; + [SCBlockClock recordBlockStartWithDuration: 600]; + [NSThread sleepForTimeInterval: 0.5]; + [SCBlockClock tickCheckpoint]; + + NSTimeInterval accumBeforeReboot = [SCBlockClock elapsedSecondsForCurrentBlock]; + + [SCBlockClock setBootSessionUUIDOverrideForTesting: @"BOOT_B"]; + NSMutableDictionary* tk = [[[SCSettings sharedSettings] valueForKey: @"BlockTimekeeping"] mutableCopy]; + tk[@"lastCheckpointWallClock"] = [NSDate dateWithTimeIntervalSinceNow: +60.0]; // future + [[SCSettings sharedSettings] setValue: tk forKey: @"BlockTimekeeping"]; + + [SCBlockClock tickCheckpoint]; + NSTimeInterval elapsedAfter = [SCBlockClock elapsedSecondsForCurrentBlock]; + XCTAssertEqualWithAccuracy(elapsedAfter, accumBeforeReboot, 1.0); // no extra credit +} + +- (void)tearDown { + [SCBlockClock setBootSessionUUIDOverrideForTesting: nil]; + [super tearDown]; +} +``` + +- [ ] **Step 4: Run, confirm failures** + +Both new tests fail because `tickCheckpoint` short-circuits on a UUID mismatch. + +- [ ] **Step 5: Update `tickCheckpoint` to handle the cross-boot case** + +Replace the "handled in Task 3" comment with: + +```objc +if (![tk[@"bootSessionUUID"] isEqualToString: [self currentBootSessionUUID]]) { + NSTimeInterval gapWall = [[NSDate date] timeIntervalSinceDate: tk[@"lastCheckpointWallClock"]]; + NSTimeInterval credit = MAX(0.0, gapWall); + NSTimeInterval newAccum = [tk[@"elapsedSecondsAccumulated"] doubleValue] + credit; + + NSMutableDictionary* updated = [tk mutableCopy]; + updated[@"elapsedSecondsAccumulated"] = @(newAccum); + updated[@"lastCheckpointWallClock"] = [NSDate date]; + updated[@"lastCheckpointContinuous"] = @([self continuousNanos]); + updated[@"bootSessionUUID"] = [self currentBootSessionUUID]; + [self writeTK: updated]; + return; +} +``` + +- [ ] **Step 6: Run, confirm pass** + +All 6 `SCBlockClockTests` should pass. + +- [ ] **Step 7: Commit** + +```bash +git add Common/Utility/SCBlockClock.h Common/Utility/SCBlockClock.m SelfControlTests/SCBlockClockTests.m +git commit -m "feat(blockclock): cross-boot reboot accounting" +``` + +--- + +## Task 4: Backward-clock detection while running + +**Files:** +- Modify: `SelfControlTests/SCBlockClockTests.m` + +**Goal:** Verify the `min(deltaContinuous, max(0, deltaWall))` rule actually credits zero when the wall clock moves backward while the daemon is running. + +No production code changes — this just locks down behavior already implemented in Task 2. + +- [ ] **Step 1: Write the test** + +Append: + +```objc +- (void)testBackwardWallClockCreditsAtMostMonotonic { + [SCBlockClock recordBlockStartWithDuration: 600]; + [NSThread sleepForTimeInterval: 0.5]; + [SCBlockClock tickCheckpoint]; + NSTimeInterval before = [SCBlockClock elapsedSecondsForCurrentBlock]; + + // Move lastCheckpointWallClock to 1 hour in the future to simulate + // sudo date -1hour having moved "now" backward relative to it. + NSMutableDictionary* tk = [[[SCSettings sharedSettings] valueForKey: @"BlockTimekeeping"] mutableCopy]; + tk[@"lastCheckpointWallClock"] = [NSDate dateWithTimeIntervalSinceNow: +3600.0]; + [[SCSettings sharedSettings] setValue: tk forKey: @"BlockTimekeeping"]; + + [SCBlockClock tickCheckpoint]; + NSTimeInterval after = [SCBlockClock elapsedSecondsForCurrentBlock]; + // The wall delta is negative; trustedDelta must be 0. + XCTAssertEqualWithAccuracy(after, before, 1.0); +} +``` + +- [ ] **Step 2: Run, confirm pass** + +This should already pass thanks to Task 2's `MAX(0.0, deltaWall)`. + +- [ ] **Step 3: Commit** + +```bash +git add SelfControlTests/SCBlockClockTests.m +git commit -m "test(blockclock): lock down backward-wall-clock behavior" +``` + +--- + +## Task 5: SCTrustedTime — HTTP Date header parsing + +**Files:** +- Create: `Common/Utility/SCTrustedTime.h` / `.m` +- Create: `SelfControlTests/SCTrustedTimeTests.m` + +**Goal:** Pure function `+ (NSDate*)parseHTTPDateHeader:(NSString*)header`. No networking yet. + +- [ ] **Step 1: Write the failing tests** + +`SelfControlTests/SCTrustedTimeTests.m`: + +```objc +#import +#import "SCTrustedTime.h" + +@interface SCTrustedTimeTests : XCTestCase +@end + +@implementation SCTrustedTimeTests + +- (void)testParseValidIMFFixdate { + NSDate* d = [SCTrustedTime parseHTTPDateHeader: @"Tue, 14 May 2026 12:00:00 GMT"]; + XCTAssertNotNil(d); + NSDateComponents* c = [[NSCalendar calendarWithIdentifier: NSCalendarIdentifierGregorian] + componentsInTimeZone: [NSTimeZone timeZoneWithAbbreviation: @"GMT"] + fromDate: d]; + XCTAssertEqual(c.year, 2026); + XCTAssertEqual(c.month, 5); + XCTAssertEqual(c.day, 14); + XCTAssertEqual(c.hour, 12); +} + +- (void)testParseGarbageReturnsNil { + XCTAssertNil([SCTrustedTime parseHTTPDateHeader: @"not a date"]); + XCTAssertNil([SCTrustedTime parseHTTPDateHeader: @""]); + XCTAssertNil([SCTrustedTime parseHTTPDateHeader: nil]); +} + +@end +``` + +- [ ] **Step 2: Run, confirm build failure** + +Expected: `SCTrustedTime.h` not found. + +- [ ] **Step 3: Create header + impl** + +`Common/Utility/SCTrustedTime.h`: + +```objc +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface SCTrustedTime : NSObject + +/// Parses an HTTP IMF-fixdate (RFC 7231 §7.1.1.1). Returns nil on any error. ++ (nullable NSDate*)parseHTTPDateHeader:(nullable NSString*)header; + +@end + +NS_ASSUME_NONNULL_END +``` + +`Common/Utility/SCTrustedTime.m`: + +```objc +#import "SCTrustedTime.h" + +@implementation SCTrustedTime + ++ (NSDateFormatter*)imfFormatter { + static NSDateFormatter* f = nil; + static dispatch_once_t once; + dispatch_once(&once, ^{ + f = [[NSDateFormatter alloc] init]; + f.locale = [NSLocale localeWithLocaleIdentifier: @"en_US_POSIX"]; + f.timeZone = [NSTimeZone timeZoneWithAbbreviation: @"GMT"]; + f.dateFormat = @"EEE, dd MMM yyyy HH:mm:ss zzz"; + }); + return f; +} + ++ (NSDate*)parseHTTPDateHeader:(NSString*)header { + if (header.length == 0) return nil; + return [[self imfFormatter] dateFromString: header]; +} + +@end +``` + +- [ ] **Step 4: Add the three new files to the right Xcode targets** + +- `SCTrustedTime.h`/`.m` → **SelfControl** target *and* daemon target. +- `SCTrustedTimeTests.m` → **SelfControlTests** only. + +- [ ] **Step 5: Run, confirm pass** + +Run `-only-testing:SelfControlTests/SCTrustedTimeTests`. Expected: 2/2 pass. + +- [ ] **Step 6: Commit** + +```bash +git add Common/Utility/SCTrustedTime.h Common/Utility/SCTrustedTime.m SelfControlTests/SCTrustedTimeTests.m SelfControl.xcodeproj/project.pbxproj +git commit -m "feat: SCTrustedTime HTTP Date parser" +``` + +--- + +## Task 6: SCPinnedHosts constants + +**Files:** +- Create: `Common/Utility/SCPinnedHosts.h` + +**Goal:** Provide the baked-in `(host, expected SPKI SHA-256)` tuples used by the pinned URLSession delegate. The hashes are computed once by hand and committed. + +- [ ] **Step 1: Compute the SPKI hashes** + +For each host, run: + +```bash +for host in www.apple.com www.google.com www.cloudflare.com; do + echo "=== $host ===" + openssl s_client -connect "$host:443" -servername "$host" /dev/null \ + | openssl x509 -pubkey -noout \ + | openssl pkey -pubin -outform der \ + | openssl dgst -sha256 -binary \ + | openssl enc -base64 +done +``` + +Record the three base64 outputs. **Note:** these hashes change when the host rotates its key. The "Open questions" section of the spec acknowledges this; for now we pin the leaf SPKI. A future task can add intermediate-CA pinning for durability. + +- [ ] **Step 2: Create the header** + +`Common/Utility/SCPinnedHosts.h`: + +```objc +#import + +NS_ASSUME_NONNULL_BEGIN + +/// (host, base64 SHA-256 of SubjectPublicKeyInfo of leaf cert). +extern NSArray*>* const SCPinnedHosts; + +NS_ASSUME_NONNULL_END +``` + +Create `Common/Utility/SCPinnedHosts.m` (so the constant has a definition site — header-only `extern` won't link): + +```objc +#import "SCPinnedHosts.h" + +NSArray*>* const SCPinnedHosts = @[ + @{ @"host": @"www.apple.com", @"spki": @"" }, + @{ @"host": @"www.google.com", @"spki": @"" }, + @{ @"host": @"www.cloudflare.com", @"spki": @"" }, +]; +``` + +- [ ] **Step 3: Add to Xcode targets** + +Both SelfControl and daemon target. + +- [ ] **Step 4: Commit** + +```bash +git add Common/Utility/SCPinnedHosts.h Common/Utility/SCPinnedHosts.m SelfControl.xcodeproj/project.pbxproj +git commit -m "feat: bake in SPKI pins for trusted time hosts" +``` + +--- + +## Task 7: SCTrustedTime — pinned URLSession single-host fetch + +**Files:** +- Modify: `Common/Utility/SCTrustedTime.h` / `.m` +- Modify: `SelfControlTests/SCTrustedTimeTests.m` + +**Goal:** `+ (void)fetchTimeFromHost:(NSString*)host expectedSPKI:(NSString*)spki completion:(...)` — does a HEAD request, validates the SPKI pin in the URLSession delegate, hands back `(NSDate*, NSError*)`. + +The unit test pins against the real Apple cert. This test requires real internet during CI, which is acceptable for a single integration test. + +- [ ] **Step 1: Extend the header** + +```objc +typedef void (^SCTrustedTimeCompletion)(NSDate* _Nullable verifiedTime, NSError* _Nullable error); + +/// Fetches https:/// HEAD and returns the time from the Date: header +/// IFF the server's leaf SubjectPublicKeyInfo SHA-256 (base64) matches expectedSPKI. ++ (void)fetchTimeFromHost:(NSString*)host + expectedSPKI:(NSString*)spki + timeout:(NSTimeInterval)timeoutSeconds + completion:(SCTrustedTimeCompletion)completion; +``` + +- [ ] **Step 2: Write the failing test** + +```objc +- (void)testFetchFromAppleWithCorrectPinReturnsDate { + XCTestExpectation* exp = [self expectationWithDescription: @"apple"]; + NSDictionary* apple = SCPinnedHosts[0]; // assumes index 0 is apple + [SCTrustedTime fetchTimeFromHost: apple[@"host"] + expectedSPKI: apple[@"spki"] + timeout: 10.0 + completion:^(NSDate* d, NSError* e) { + XCTAssertNil(e); + XCTAssertNotNil(d); + XCTAssertLessThan(ABS([d timeIntervalSinceNow]), 120.0); // server time within 2 min of ours + [exp fulfill]; + }]; + [self waitForExpectations: @[exp] timeout: 15.0]; +} + +- (void)testFetchWithWrongPinFails { + XCTestExpectation* exp = [self expectationWithDescription: @"badpin"]; + [SCTrustedTime fetchTimeFromHost: @"www.apple.com" + expectedSPKI: @"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + timeout: 10.0 + completion:^(NSDate* d, NSError* e) { + XCTAssertNotNil(e); + XCTAssertNil(d); + [exp fulfill]; + }]; + [self waitForExpectations: @[exp] timeout: 15.0]; +} +``` + +Add to imports: `#import "SCPinnedHosts.h"`. + +- [ ] **Step 3: Run, confirm failures** + +Both tests fail — method doesn't exist. + +- [ ] **Step 4: Implement the pinned fetch** + +Add to `SCTrustedTime.m`: + +```objc +#import +#import + +@interface SCTrustedTimePinningDelegate : NSObject +@property (copy) NSString* expectedSPKI; +@end + +@implementation SCTrustedTimePinningDelegate + +- (void)URLSession:(NSURLSession*)session +didReceiveChallenge:(NSURLAuthenticationChallenge*)challenge + completionHandler:(void(^)(NSURLSessionAuthChallengeDisposition, NSURLCredential* _Nullable))ch +{ + if (![challenge.protectionSpace.authenticationMethod isEqualToString: NSURLAuthenticationMethodServerTrust]) { + ch(NSURLSessionAuthChallengeCancelAuthenticationChallenge, nil); + return; + } + SecTrustRef trust = challenge.protectionSpace.serverTrust; + if (trust == NULL) { ch(NSURLSessionAuthChallengeCancelAuthenticationChallenge, nil); return; } + + CFIndex count = SecTrustGetCertificateCount(trust); + BOOL matched = NO; + for (CFIndex i = 0; i < count && !matched; i++) { + SecCertificateRef cert = SecTrustGetCertificateAtIndex(trust, i); + SecKeyRef pubkey = SecCertificateCopyKey(cert); + if (pubkey == NULL) continue; + CFErrorRef err = NULL; + CFDataRef der = SecKeyCopyExternalRepresentation(pubkey, &err); + CFRelease(pubkey); + if (der == NULL) continue; + unsigned char digest[CC_SHA256_DIGEST_LENGTH]; + CC_SHA256(CFDataGetBytePtr(der), (CC_LONG)CFDataGetLength(der), digest); + NSData* digestData = [NSData dataWithBytes: digest length: CC_SHA256_DIGEST_LENGTH]; + NSString* b64 = [digestData base64EncodedStringWithOptions: 0]; + CFRelease(der); + if ([b64 isEqualToString: self.expectedSPKI]) matched = YES; + } + + if (matched) { + ch(NSURLSessionAuthChallengeUseCredential, [NSURLCredential credentialForTrust: trust]); + } else { + ch(NSURLSessionAuthChallengeCancelAuthenticationChallenge, nil); + } +} + +@end + + ++ (void)fetchTimeFromHost:(NSString*)host + expectedSPKI:(NSString*)spki + timeout:(NSTimeInterval)timeoutSeconds + completion:(SCTrustedTimeCompletion)completion +{ + SCTrustedTimePinningDelegate* d = [SCTrustedTimePinningDelegate new]; + d.expectedSPKI = spki; + NSURLSessionConfiguration* cfg = [NSURLSessionConfiguration ephemeralSessionConfiguration]; + cfg.timeoutIntervalForRequest = timeoutSeconds; + NSURLSession* session = [NSURLSession sessionWithConfiguration: cfg delegate: d delegateQueue: nil]; + + NSURL* url = [NSURL URLWithString: [NSString stringWithFormat: @"https://%@/", host]]; + NSMutableURLRequest* req = [NSMutableURLRequest requestWithURL: url]; + req.HTTPMethod = @"HEAD"; + + [[session dataTaskWithRequest: req completionHandler:^(NSData* data, NSURLResponse* resp, NSError* error) { + [session finishTasksAndInvalidate]; + if (error) { completion(nil, error); return; } + NSHTTPURLResponse* http = (NSHTTPURLResponse*)resp; + NSString* dateStr = http.allHeaderFields[@"Date"]; + NSDate* parsed = [SCTrustedTime parseHTTPDateHeader: dateStr]; + if (parsed == nil) { + completion(nil, [NSError errorWithDomain: @"SCTrustedTime" code: 1 + userInfo: @{NSLocalizedDescriptionKey: @"No/invalid Date header"}]); + return; + } + completion(parsed, nil); + }] resume]; +} +``` + +- [ ] **Step 5: Run, confirm pass** + +Both tests should pass given internet. (CI without internet → testFetchFromAppleWithCorrectPinReturnsDate will fail; that is expected and the CI config in this repo's `config.yml` already accepts an offline run when needed. If you must run offline, mark it `XCTSkipIf` instead of removing.) + +- [ ] **Step 6: Commit** + +```bash +git add Common/Utility/SCTrustedTime.h Common/Utility/SCTrustedTime.m SelfControlTests/SCTrustedTimeTests.m +git commit -m "feat(trustedtime): pinned HEAD fetch with SPKI verification" +``` + +--- + +## Task 8: SCTrustedTime — multi-host quorum + +**Files:** +- Modify: `Common/Utility/SCTrustedTime.h` / `.m` +- Modify: `SelfControlTests/SCTrustedTimeTests.m` + +**Goal:** `+ (void)verifyTimeIsAfter:(NSDate*)threshold completion:(...)` — query all pinned hosts in parallel; succeed iff ≥2 hosts return successfully and all returned times are after `threshold` and within 5 minutes of each other. + +- [ ] **Step 1: Extend the header** + +```objc +typedef void (^SCTrustedTimeQuorumCompletion)(BOOL verified, NSDate* _Nullable medianTime, NSError* _Nullable error); + +/// Returns verified=YES iff at least 2 pinned hosts respond, all responses are +/// within 5 minutes of each other, and the median response is >= threshold. ++ (void)verifyTimeIsAfter:(NSDate*)threshold + completion:(SCTrustedTimeQuorumCompletion)completion; +``` + +- [ ] **Step 2: Write the failing test** + +```objc +- (void)testVerifyAfterPastThresholdSucceeds { + XCTestExpectation* exp = [self expectationWithDescription: @"quorum"]; + NSDate* past = [NSDate dateWithTimeIntervalSinceNow: -3600]; + [SCTrustedTime verifyTimeIsAfter: past + completion:^(BOOL ok, NSDate* med, NSError* err) { + XCTAssertTrue(ok); + XCTAssertNotNil(med); + [exp fulfill]; + }]; + [self waitForExpectations: @[exp] timeout: 20.0]; +} + +- (void)testVerifyAfterFutureThresholdFails { + XCTestExpectation* exp = [self expectationWithDescription: @"future"]; + NSDate* future = [NSDate dateWithTimeIntervalSinceNow: 86400 * 365 * 10]; // 10 years + [SCTrustedTime verifyTimeIsAfter: future + completion:^(BOOL ok, NSDate* med, NSError* err) { + XCTAssertFalse(ok); + [exp fulfill]; + }]; + [self waitForExpectations: @[exp] timeout: 20.0]; +} +``` + +- [ ] **Step 3: Run, confirm failures** + +- [ ] **Step 4: Implement** + +In `SCTrustedTime.m`, add: + +```objc ++ (void)verifyTimeIsAfter:(NSDate*)threshold completion:(SCTrustedTimeQuorumCompletion)completion { + NSArray* hosts = SCPinnedHosts; + dispatch_group_t group = dispatch_group_create(); + NSMutableArray* results = [NSMutableArray array]; + NSLock* lock = [NSLock new]; + + for (NSDictionary* hp in hosts) { + dispatch_group_enter(group); + [self fetchTimeFromHost: hp[@"host"] + expectedSPKI: hp[@"spki"] + timeout: 8.0 + completion:^(NSDate* d, NSError* e) { + if (d != nil) { + [lock lock]; [results addObject: d]; [lock unlock]; + } + dispatch_group_leave(group); + }]; + } + + dispatch_group_notify(group, dispatch_get_main_queue(), ^{ + if (results.count < 2) { + completion(NO, nil, [NSError errorWithDomain: @"SCTrustedTime" code: 2 + userInfo: @{NSLocalizedDescriptionKey: @"Quorum not reached"}]); + return; + } + NSArray* sorted = [results sortedArrayUsingSelector: @selector(compare:)]; + NSDate* min = sorted.firstObject; + NSDate* max = sorted.lastObject; + if ([max timeIntervalSinceDate: min] > 300.0) { + completion(NO, nil, [NSError errorWithDomain: @"SCTrustedTime" code: 3 + userInfo: @{NSLocalizedDescriptionKey: @"Pinned hosts disagree"}]); + return; + } + NSDate* median = sorted[sorted.count / 2]; + BOOL ok = [median compare: threshold] != NSOrderedAscending; + completion(ok, median, nil); + }); +} +``` + +- [ ] **Step 5: Run, confirm pass** + +- [ ] **Step 6: Commit** + +```bash +git add Common/Utility/SCTrustedTime.h Common/Utility/SCTrustedTime.m SelfControlTests/SCTrustedTimeTests.m +git commit -m "feat(trustedtime): multi-host quorum check" +``` + +--- + +## Task 9: Wire SCBlockClock into block start + +**Files:** +- Modify: `Daemon/SCDaemonBlockMethods.m` (around line 87) + +**Goal:** when `startBlock` records `BlockEndDate`, also call `[SCBlockClock recordBlockStartWithDuration:]` so the monotonic counter is anchored. + +- [ ] **Step 1: Add the call** + +Locate the block in `startBlockWithControllingUID:...` that sets `BlockEndDate`: + +```objc +[settings setValue: endDate forKey: @"BlockEndDate"]; +``` + +Add immediately after: + +```objc +NSTimeInterval duration = [endDate timeIntervalSinceNow]; +if (duration > 0) { + [SCBlockClock recordBlockStartWithDuration: duration]; +} +``` + +Add `#import "SCBlockClock.h"` at the top. + +- [ ] **Step 2: Build the daemon target** + +Run: `xcodebuild -workspace SelfControl.xcworkspace -scheme org.eyebeam.selfcontrold build` +Expected: clean build. + +- [ ] **Step 3: Commit** + +```bash +git add Daemon/SCDaemonBlockMethods.m +git commit -m "feat(daemon): record SCBlockClock start when a block begins" +``` + +--- + +## Task 10: Replace expiry check with three-signal gate + +**Files:** +- Modify: `Common/Utility/SCBlockUtilities.h` / `.m` +- Modify: `SelfControlTests/SCUtilityTests.m` + +**Goal:** add `+ (BOOL)currentBlockIsTrulyExpired` which returns YES only when **both** `currentBlockIsExpired` (wall clock) **and** `[SCBlockClock blockDurationHasElapsed]` are YES. Callers in the daemon switch over. + +We deliberately keep the old `currentBlockIsExpired` because non-daemon callers (UI hints, migration code) still want the weaker signal. + +- [ ] **Step 1: Add to the header** + +In `SCBlockUtilities.h`, alongside `currentBlockIsExpired`: + +```objc +/// Strict check: both wall-clock and monotonic counter say the block is over. +/// Use this in the daemon before tearing down firewall rules. ++ (BOOL)currentBlockIsTrulyExpired; +``` + +- [ ] **Step 2: Write the failing test** + +In `SCUtilityTests.m` (`testModernBlockDetection` is a good neighbor): + +```objc +- (void)testTrulyExpiredRequiresBothSignals { + SCSettings* s = [SCSettings sharedSettings]; + [s setValue: @YES forKey: @"BlockIsRunning"]; + [s setValue: [NSDate dateWithTimeIntervalSinceNow: -10] forKey: @"BlockEndDate"]; // wall says expired + [SCBlockClock recordBlockStartWithDuration: 600]; // monotonic says NOT expired + + XCTAssertTrue([SCBlockUtilities currentBlockIsExpired]); + XCTAssertFalse([SCBlockUtilities currentBlockIsTrulyExpired]); // 👈 the new gate + + // now flip monotonic to also expired + NSMutableDictionary* tk = [[s valueForKey: @"BlockTimekeeping"] mutableCopy]; + tk[@"elapsedSecondsAccumulated"] = @(99999); + [s setValue: tk forKey: @"BlockTimekeeping"]; + XCTAssertTrue([SCBlockUtilities currentBlockIsTrulyExpired]); +} +``` + +Add `#import "SCBlockClock.h"` to the test file. + +- [ ] **Step 3: Run, confirm failure** + +- [ ] **Step 4: Implement** + +In `SCBlockUtilities.m`: + +```objc +#import "SCBlockClock.h" + ++ (BOOL)currentBlockIsTrulyExpired { + if (![self currentBlockIsExpired]) return NO; + return [SCBlockClock blockDurationHasElapsed]; +} +``` + +- [ ] **Step 5: Run, confirm pass** + +- [ ] **Step 6: Commit** + +```bash +git add Common/Utility/SCBlockUtilities.h Common/Utility/SCBlockUtilities.m SelfControlTests/SCUtilityTests.m +git commit -m "feat: currentBlockIsTrulyExpired requires monotonic agreement" +``` + +--- + +## Task 11: Use the strict gate in daemon checkup + +**Files:** +- Modify: `Daemon/SCDaemonBlockMethods.m` (around line 313) + +**Goal:** the `checkupBlock` branch that removes the firewall rules when the block is "expired" must now use `currentBlockIsTrulyExpired`. Without this, Tasks 1–10 are dead code. + +- [ ] **Step 1: Edit `checkupBlock`** + +Find: + +```objc +} else if ([SCBlockUtilities currentBlockIsExpired]) { + NSLog(@"INFO: Checkup ran, block expired, removing block."); + [SCHelperToolUtilities removeBlock]; + ... +} +``` + +Replace `currentBlockIsExpired` with `currentBlockIsTrulyExpired`. + +- [ ] **Step 2: Build the daemon target, confirm clean build** + +- [ ] **Step 3: Commit** + +```bash +git add Daemon/SCDaemonBlockMethods.m +git commit -m "feat(daemon): require strict (wall + monotonic) gate before unlock" +``` + +--- + +## Task 12: Add the 30-second checkpoint timer in the daemon + +**Files:** +- Modify: `Daemon/SCDaemon.h` / `.m` + +**Goal:** every 30 s while a block is running, call `[SCBlockClock tickCheckpoint]`. The existing daemon already has the timer pattern (`checkupTimer`, `inactivityTimer`); model after it. + +- [ ] **Step 1: Add methods to the header** + +In `SCDaemon.h`, alongside `startCheckupTimer` etc.: + +```objc +- (void)startCheckpointTimer; +- (void)stopCheckpointTimer; +``` + +- [ ] **Step 2: Add the timer property and methods to `.m`** + +```objc +@property (strong, readwrite) NSTimer* checkpointTimer; +``` + +```objc +- (void)startCheckpointTimer { + if (self.checkpointTimer != nil) return; + self.checkpointTimer = [NSTimer scheduledTimerWithTimeInterval: 30.0 + repeats: YES + block: ^(NSTimer* _Nonnull t) { + [SCBlockClock tickCheckpoint]; + }]; +} + +- (void)stopCheckpointTimer { + if (self.checkpointTimer == nil) return; + [self.checkpointTimer invalidate]; + self.checkpointTimer = nil; +} +``` + +In the `dealloc` / shutdown section, add: + +```objc +if (self.checkpointTimer) { + [self.checkpointTimer invalidate]; + self.checkpointTimer = nil; +} +``` + +Add `#import "SCBlockClock.h"` near the top. + +- [ ] **Step 3: Start/stop the timer from `startBlock` and from removal** + +In `Daemon/SCDaemonBlockMethods.m`, near the existing `[[SCDaemon sharedDaemon] startCheckupTimer];` call inside `startBlock`, add: + +```objc +[[SCDaemon sharedDaemon] startCheckpointTimer]; +``` + +In `SCHelperToolUtilities.m` `removeBlock` (find the existing call that clears block settings) — add `[[SCDaemon sharedDaemon] stopCheckpointTimer];` only if `SCDaemon` is reachable from there; if not, do it where `stopCheckupTimer` is called in `checkupBlock`. + +- [ ] **Step 4: Build, confirm clean build** + +- [ ] **Step 5: Commit** + +```bash +git add Daemon/SCDaemon.h Daemon/SCDaemon.m Daemon/SCDaemonBlockMethods.m Common/Utility/SCHelperToolUtilities.m +git commit -m "feat(daemon): 30s checkpoint timer for SCBlockClock" +``` + +--- + +## Task 13: Internet verification gate at unlock time + +**Files:** +- Modify: `Daemon/SCDaemonBlockMethods.m` + +**Goal:** in the `checkupBlock` "block is truly expired" branch, before calling `removeBlock`, call `[SCTrustedTime verifyTimeIsAfter: blockEndDate completion:^(BOOL ok, ...)]`. Only remove the block when `ok == YES`. If `ok == NO`, set `BlockUnlockGate.waitingForNetworkVerification = YES` in settings and schedule a retry. + +- [ ] **Step 1: Define a small helper inside `SCDaemonBlockMethods.m`** + +Above `@implementation SCDaemonBlockMethods`: + +```objc +static NSTimeInterval const kVerifyBackoffs[] = { 10.0, 30.0, 60.0, 120.0, 120.0 }; +static const NSUInteger kVerifyBackoffsCount = sizeof(kVerifyBackoffs) / sizeof(kVerifyBackoffs[0]); +``` + +- [ ] **Step 2: Refactor the "truly expired" branch in `checkupBlock`** + +Replace: + +```objc +} else if ([SCBlockUtilities currentBlockIsTrulyExpired]) { + NSLog(@"INFO: Checkup ran, block expired, removing block."); + [SCHelperToolUtilities removeBlock]; + [SCHelperToolUtilities sendConfigurationChangedNotification]; + [SCSentry addBreadcrumb: @"Daemon found and cleared expired block" category: @"daemon"]; + [[SCDaemon sharedDaemon] stopCheckupTimer]; +} +``` + +with: + +```objc +} else if ([SCBlockUtilities currentBlockIsTrulyExpired]) { + [SCDaemonBlockMethods attemptVerifiedUnlock]; +} +``` + +Then add the helper method: + +```objc ++ (void)attemptVerifiedUnlock { + SCSettings* settings = [SCSettings sharedSettings]; + NSDate* endDate = [settings valueForKey: @"BlockEndDate"]; + NSUInteger attempt = [[settings valueForKey: @"BlockUnlockAttempt"] unsignedIntegerValue]; + + NSMutableDictionary* gate = [[settings valueForKey: @"BlockUnlockGate"] mutableCopy] ?: [NSMutableDictionary dictionary]; + gate[@"waitingForNetworkVerification"] = @YES; + gate[@"lastNetworkAttemptAt"] = [NSDate date]; + [settings setValue: gate forKey: @"BlockUnlockGate"]; + [SCHelperToolUtilities sendConfigurationChangedNotification]; + + [SCTrustedTime verifyTimeIsAfter: endDate completion:^(BOOL ok, NSDate* med, NSError* err) { + if (ok) { + NSLog(@"INFO: Verified unlock — removing block."); + [settings setValue: nil forKey: @"BlockUnlockGate"]; + [settings setValue: @(0) forKey: @"BlockUnlockAttempt"]; + [SCHelperToolUtilities removeBlock]; + [SCHelperToolUtilities sendConfigurationChangedNotification]; + [[SCDaemon sharedDaemon] stopCheckupTimer]; + [[SCDaemon sharedDaemon] stopCheckpointTimer]; + return; + } + + NSLog(@"WARN: Trusted-time verification failed: %@. Block stays on.", err); + NSMutableDictionary* g = [[settings valueForKey: @"BlockUnlockGate"] mutableCopy]; + g[@"lastNetworkErrorReason"] = err.localizedDescription ?: @"unknown"; + [settings setValue: g forKey: @"BlockUnlockGate"]; + [settings setValue: @(attempt + 1) forKey: @"BlockUnlockAttempt"]; + + NSTimeInterval backoff = kVerifyBackoffs[ MIN(attempt, kVerifyBackoffsCount - 1) ]; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(backoff * NSEC_PER_SEC)), + dispatch_get_main_queue(), + ^{ [SCDaemonBlockMethods attemptVerifiedUnlock]; }); + }]; +} +``` + +Add `#import "SCTrustedTime.h"` and `#import "SCBlockClock.h"`. + +- [ ] **Step 3: Build the daemon, confirm clean build** + +- [ ] **Step 4: Commit** + +```bash +git add Daemon/SCDaemonBlockMethods.m +git commit -m "feat(daemon): require trusted-internet verification before unlock" +``` + +--- + +## Task 14: Expose unlock-gate state over XPC + +**Files:** +- Modify: `Daemon/SCDaemonProtocol.h` +- Modify: `Daemon/SCDaemonXPC.m` +- Modify: `Common/SCXPCClient.h` / `.m` + +**Goal:** the app side can ask the daemon "are we currently waiting on internet verification?" so the timer window can show the message. + +- [ ] **Step 1: Add the protocol method** + +`SCDaemonProtocol.h`: + +```objc +- (void)getBlockUnlockGateStateWithReply:(void(^)(BOOL waitingForNetwork, NSDate* lastAttemptAt, NSString* errorReason))reply; +``` + +- [ ] **Step 2: Implement in `SCDaemonXPC.m`** + +```objc +- (void)getBlockUnlockGateStateWithReply:(void(^)(BOOL, NSDate*, NSString*))reply { + NSDictionary* gate = [[SCSettings sharedSettings] valueForKey: @"BlockUnlockGate"]; + BOOL waiting = [gate[@"waitingForNetworkVerification"] boolValue]; + NSDate* at = gate[@"lastNetworkAttemptAt"]; + NSString* err = gate[@"lastNetworkErrorReason"]; + reply(waiting, at, err); +} +``` + +- [ ] **Step 3: Add the convenience method in `SCXPCClient.h` and `.m`** + +```objc +- (void)getBlockUnlockGateStateWithReply:(void(^)(BOOL waiting, NSDate* lastAttemptAt, NSString* errorReason))reply; +``` + +Implementation pattern: copy the structure of any existing one-way getter (e.g., `getVersionWithReply:`). + +- [ ] **Step 4: Build both targets** + +- [ ] **Step 5: Commit** + +```bash +git add Daemon/SCDaemonProtocol.h Daemon/SCDaemonXPC.m Common/SCXPCClient.h Common/SCXPCClient.m +git commit -m "feat(xpc): expose unlock-gate state to the app" +``` + +--- + +## Task 15: Show "waiting for internet" in TimerWindowController + +**Files:** +- Modify: `TimerWindowController.m` + +**Goal:** when the timer's existing periodic update notices the block "should" be over (wall-clock past `BlockEndDate`) but `BlockIsRunning` is still YES, query the unlock gate. If `waitingForNetwork`, replace the time label with the user-facing message. + +- [ ] **Step 1: Identify the periodic update entry point** + +In `TimerWindowController.m`, find the method that updates the displayed time (search for the timer label outlet, e.g. `timerLabel.stringValue = …`). This is the place that already runs on each second/tick. + +- [ ] **Step 2: Add the gate-aware branch** + +Inside that method, before any code that sets the time-remaining string, add: + +```objc +SCSettings* settings = [SCSettings sharedSettings]; +NSDate* endDate = [settings valueForKey: @"BlockEndDate"]; +BOOL pastEnd = [[NSDate date] timeIntervalSinceDate: endDate] > 0; +if (pastEnd && [SCBlockUtilities modernBlockIsRunning]) { + [[SCXPCClient sharedXPC] getBlockUnlockGateStateWithReply:^(BOOL waiting, NSDate* at, NSString* err) { + dispatch_async(dispatch_get_main_queue(), ^{ + if (waiting) { + self.timerLabel.stringValue = + NSLocalizedString(@"Connect to the internet to finish your block.", + @"Shown when block end time has passed but trusted-time check has not yet succeeded."); + self.timerSubLabel.stringValue = + NSLocalizedString(@"SelfControl checks the time with a trusted server. Once online, this finishes automatically.", + @""); + } + }); + }]; + return; +} +``` + +(Adapt outlet names — `timerLabel`, `timerSubLabel` — to whatever the controller already uses. If only one label exists, append the sublabel text on a new line.) + +- [ ] **Step 3: Add new strings to `Localizable.strings`** + +In `en.lproj/Localizable.strings`: + +``` +"Connect to the internet to finish your block." = "Connect to the internet to finish your block."; +"SelfControl checks the time with a trusted server. Once online, this finishes automatically." = "SelfControl checks the time with a trusted server. Once online, this finishes automatically."; +``` + +- [ ] **Step 4: Build the SelfControl app target, confirm clean build** + +- [ ] **Step 5: Commit** + +```bash +git add TimerWindowController.m en.lproj/Localizable.strings +git commit -m "feat(ui): show 'waiting for internet' when unlock gate is open" +``` + +--- + +## Task 16: Manual end-to-end QA + +**Files:** none — this is a smoke-test checklist. + +**Goal:** prove that the four spec scenarios all behave as documented. + +Build a Debug variant first: `xcodebuild -workspace SelfControl.xcworkspace -scheme SelfControl -configuration Debug build`. Install/run from `~/Library/Developer/Xcode/DerivedData/...`. + +- [ ] **Test 1: `sudo date` attack** + 1. Start a 5-minute block on `example.com`. + 2. Confirm `curl https://example.com` fails. + 3. Run `sudo date 010100002030` (Jan 1 2030). + 4. Wait 10 s. Confirm the block is **still active** (curl still fails) and the timer still counts down toward the real end. + 5. Restore time: `sudo sntp -sS time.apple.com`. + +- [ ] **Test 2: Reboot mid-block** + 1. Start a 10-minute block. + 2. Reboot the Mac. + 3. After login, confirm the block is still active and the remaining-time display is close to what it should be. + +- [ ] **Test 3: `/etc/hosts` redirect** + 1. Start a 1-minute block. + 2. Edit `/etc/hosts` to add `127.0.0.1 www.apple.com www.google.com www.cloudflare.com`. + 3. Wait past the 1-minute end. + 4. Confirm the timer window shows the "Connect to the internet" message and the block does **not** unlock. + 5. Remove the `/etc/hosts` lines. + 6. Confirm the block unlocks within ~30 s. + +- [ ] **Test 4: Wi-Fi off at unlock** + 1. Start a 1-minute block. + 2. Disable Wi-Fi at expiry. + 3. Confirm "Connect to the internet" message appears, block stays on. + 4. Re-enable Wi-Fi. + 5. Confirm block unlocks within ~30 s. + +- [ ] **Document any issues** in a follow-up GitHub issue. Do not gate the merge on a perfect manual run — file fixes as separate PRs. + +- [ ] **Final commit** (if any docs were tweaked): + +```bash +git add docs/superpowers/plans/2026-05-14-tamper-resistant-timing.md +git commit -m "docs: QA results from tamper-resistant timing" +``` + +--- + +## Self-Review Notes + +**Spec coverage:** +- Piece 1 (monotonic counter) → Tasks 1, 2, 9. +- Piece 2 (persisted checkpoint) → Tasks 2, 3, 4, 12. +- Piece 3 (pinned HTTPS) → Tasks 5, 6, 7, 8, 13. +- Piece 4 (UI) → Tasks 14, 15. +- Threat-model attacks (rows in the spec table) → all covered by the above. Sudo+reboot+clock-change: caught by Piece 3 (Task 13), since the daemon refuses to unlock without quorum agreement that the real time is past `BlockEndDate`. + +**Placeholder scan:** the only `` placeholders are the SPKI hashes in Task 6, which must be computed at implementation time (the command is provided). These are intentional, not stubs. + +**Type consistency:** `SCBlockClock` method names match across Tasks 1, 2, 3, 9, 10, 12. `SCTrustedTime` block typedefs (`SCTrustedTimeCompletion`, `SCTrustedTimeQuorumCompletion`) declared once and used consistently. + +**Known small risks for the implementer:** +- `SecCertificateCopyKey` is macOS 10.14+. The repo already requires 10.13 minimum per the existing podfile; if 10.13 support matters, fall back to `SecCertificateCopyPublicKey` (deprecated). Check `Podfile`. +- `[NSTimer scheduledTimerWithTimeInterval: 30 …]` requires a runloop. The daemon does have one (see `SCDaemon.m` existing timers), so this should Just Work. diff --git a/docs/superpowers/specs/2026-05-14-tamper-resistant-timing-design.md b/docs/superpowers/specs/2026-05-14-tamper-resistant-timing-design.md new file mode 100644 index 00000000..3ec4a19b --- /dev/null +++ b/docs/superpowers/specs/2026-05-14-tamper-resistant-timing-design.md @@ -0,0 +1,259 @@ +# Tamper-Resistant Block Timing — Design + +**Date:** 2026-05-14 +**Status:** Approved (design phase). Implementation plan not yet written. + +## Problem + +A SelfControl block today ends when `[NSDate date]` (the macOS system clock) passes `BlockEndDate`. Any user can defeat the block by running `sudo date 0101000026` in Terminal to jump the clock forward. The block ends instantly. The protection is one search away on any AI assistant. + +We want to close this hole. + +## Threat model + +We are defending against a casual user who copy-pastes a bypass from an AI assistant. We are **not** trying to stop a skilled reverse engineer who edits the SelfControl binary, attaches a debugger, or disables the PF firewall directly. + +Concretely, we defend against: + +- `sudo date` to jump the system clock forward or backward +- Editing the on-disk settings/checkpoint files +- Deleting the on-disk settings/checkpoint files +- Rebooting the Mac mid-block (with or without a clock change) +- Editing `/etc/hosts` to redirect time-server lookups +- Installing a custom root certificate to MITM HTTPS +- Blocking all network traffic + +In every case the desired behavior is the same: **the block stays on.** "When in doubt, stay blocked" is the default. + +We do **not** defend against: + +- Patching the SelfControl binary itself +- Disabling SIP and tampering with the PF kernel module +- Booting from another OS and editing the disk offline +- Physical attacks + +## Design overview + +Three pieces, layered: + +1. A **monotonic counter** (`mach_continuous_time`) so changing the wall clock does not end the block while the Mac is running. +2. A **persisted checkpoint** so the elapsed-time accounting survives reboots and sleep. +3. A **pinned HTTPS time check** at unlock so an attacker who edits the local files still cannot fake the moment the block ends. + +The existing `BlockEndDate` is kept as a sanity check. To unlock, *all* three signals must agree. + +## Piece 1 — Monotonic counter + +### What + +`mach_continuous_time()` returns nanoseconds since boot. It is unaffected by `sudo date` and keeps counting during sleep. It resets to 0 on reboot (handled by Piece 2). + +### How + +At block start, the daemon records: + +- `blockStartContinuousTime` — value of `mach_continuous_time()` at block start +- `blockDurationSeconds` — requested duration + +On every existing block-state check, compute: + +``` +continuousElapsed = (mach_continuous_time() - blockStartContinuousTime) / 1e9 +``` + +If `continuousElapsed < blockDurationSeconds`, **block stays on**, even if `BlockEndDate` has passed. + +### What this defeats + +`sudo date` while the Mac is running. The monotonic counter does not change when the wall clock is changed. + +## Piece 2 — Persisted checkpoint + +### What + +Because the monotonic counter resets on reboot, we periodically write a small note to disk recording how much elapsed time has accumulated. After a reboot the daemon reads the note and picks up where it left off. + +### How + +A new key in `SCSettings`, written to `/usr/local/etc/` (root-owned, same protection as today's settings): + +``` +BlockTimekeeping = { + blockStartWallClock: + blockStartContinuousTime: // mach_continuous_time at start + bootSessionUUID: // identifies "this boot" + elapsedSecondsAccumulated: // total across all boots so far + lastCheckpointWallClock: // wall clock at last write + lastCheckpointContinuous: // monotonic value at last write +} +``` + +A timer in the daemon updates the checkpoint **every 30 seconds**. + +At each checkpoint write: + +``` +deltaContinuous = mach_continuous_time() - lastCheckpointContinuous +deltaWall = now() - lastCheckpointWallClock + +// Trust the smaller of the two. If they disagree by more than a small +// tolerance, that is a tamper signal (clock changed while running). We +// still credit the smaller of the two, never the larger. +trustedDelta = min(deltaContinuous_in_seconds, max(0, deltaWall_in_seconds)) + +elapsedSecondsAccumulated += trustedDelta +``` + +On daemon startup (e.g., after reboot): + +1. Read the persisted `bootSessionUUID`. Compute the current one as the string form of `kern.boottime` (a per-boot timestamp from `sysctl`). If they match, we are still in the same boot session: `blockStartContinuousTime` and `lastCheckpointContinuous` are still valid, so we resume normal checkpoint accounting. +2. If they differ, we rebooted. The monotonic counter has reset, so we cannot use it for the gap. We compute `gapWall = now() - lastCheckpointWallClock`. If `gapWall > 0`, we credit that much elapsed time (treating the reboot gap as honest wall-clock time). If `gapWall <= 0`, we credit zero — clock went backward across the reboot, which is a tamper signal. +3. We then reset `blockStartContinuousTime` and `lastCheckpointContinuous` to the current `mach_continuous_time()`, update `bootSessionUUID` to the current boot timestamp, and resume normal accounting. + +To unlock without Piece 3, the daemon requires: + +- `elapsedSecondsAccumulated >= blockDurationSeconds`, AND +- `[NSDate date] >= blockEndDate` + +### What this defeats + +- Reboot mid-block: the note's wall-clock + accumulated total preserves real elapsed time. +- Editing the on-disk note: if `elapsedSecondsAccumulated` is set to a fake huge value, Piece 3 catches it. If the note is deleted, the daemon treats elapsed as zero and stays blocked. +- Tampering while running: `min(deltaContinuous, deltaWall)` makes either direction of wall-clock change a no-op for accumulated time. + +### Failure modes + +- Missing/corrupt note → assume zero elapsed → block stays on. +- Clock-going-backward signal → can be logged for telemetry but does not shorten the block. + +## Piece 3 — Pinned HTTPS time check at unlock + +### What + +Before the daemon transitions a block from "active" to "ended," it makes one outbound HTTPS request to a pinned set of servers and verifies the real wall-clock time from the response `Date:` header. If verification fails for any reason, the block stays active. + +### Servers + +A small list baked into the binary, e.g.: + +- `https://www.apple.com` +- `https://www.google.com` +- `https://www.cloudflare.com` + +We require **at least 2 of 3** to succeed and agree (within ~5 minutes of each other) that the block end time has passed. If fewer than 2 respond with a valid pinned cert, the check fails. + +### Pinning + +For each server we bake in a SHA-256 fingerprint of the **subject public key** of the leaf or an intermediate CA. (Public-key pinning is more durable than full-cert pinning across renewals.) + +Implementation uses `URLSession` with a `URLSessionDelegate` that: + +1. Receives `URLAuthenticationChallenge` of type `NSURLAuthenticationMethodServerTrust`. +2. Extracts the server certificate chain from `serverTrust`. +3. For each cert in the chain, computes SHA-256 of the SubjectPublicKeyInfo (SPKI). +4. If any computed hash matches the pinned hash for that host, the challenge passes. Otherwise, the challenge is rejected via `.cancelAuthenticationChallenge`. + +Crucially, the delegate does **not** fall back to the system trust evaluation. A user-installed root CA cannot satisfy the pin. + +### Request + +`HEAD /` to each pinned URL. Parse the `Date:` header (RFC 7231 IMF-fixdate). Discard responses with malformed dates or with TLS warnings. + +### Decision + +The block is allowed to end iff: + +- Piece 1 says continuous elapsed ≥ duration, AND +- Piece 2 says accumulated elapsed ≥ duration, AND +- Piece 3 returns ≥ 2 valid pinned responses whose Date headers all show a time ≥ `BlockEndDate`. + +Any one of those failing → stay blocked. + +### What this defeats + +- Disk tampering of the checkpoint note (`elapsedSecondsAccumulated` forged): Piece 3 still asks the real internet. +- `/etc/hosts` redirecting `www.apple.com` to a local server: pinned key won't match. +- A user-installed root CA enabling MITM: pinned key won't match. +- Network blackholing: no responses → block stays. + +## Piece 4 — UI feedback (new in this design pass) + +When the daemon would have ended the block based on local signals (Pieces 1 + 2 both green) but Piece 3 fails, the timer window must tell the user **why** the block is not ending. + +### What the user sees + +A new state in the existing `TimerWindowController` UI, shown when the daemon is waiting on Piece 3: + +> **Connect to the internet to finish your block.** +> SelfControl checks the time with a trusted server before ending a block. Once you're online, this will finish automatically. + +Visually: small spinner + the message. No "skip" button. No "unlock anyway" override. + +### How + +A new state field passed from daemon → app over the existing XPC channel: + +``` +BlockUnlockGate { + waitingForNetworkVerification: BOOL + lastNetworkAttemptAt: NSDate + lastNetworkErrorReason: NSString? // shown in advanced/debug only +} +``` + +The daemon retries Piece 3 on a backoff (e.g., 10s, 30s, 60s, then every 2 min) while in this state. The timer window observes the XPC state and shows the message whenever `waitingForNetworkVerification == YES`. + +### Edge cases + +- User puts Mac to sleep in this state: on wake, daemon retries Piece 3 immediately, then continues backoff. +- Block duration was very short (e.g., 1 minute) and internet was offline at the moment of unlock: user may be blocked for an extra few seconds to minutes while the verification succeeds. Acceptable. +- Internet remains down for hours: block stays on. UI keeps showing the message. This is **by design** — the alternative is letting an attacker win by cutting Wi-Fi. + +## File-level changes + +- `Common/SCSettings.m` + `.h`: add `BlockTimekeeping` dictionary key and accessors. +- `Common/Utility/SCBlockClock.m` + `.h` *(new)*: monotonic-counter math, checkpoint read/write, boot-UUID handling, the "min(deltaWall, deltaContinuous)" rule. +- `Common/Utility/SCTrustedTime.m` + `.h` *(new)*: pinned HTTPS time check; takes a list of (host, SPKI hash) pairs; returns verified time or error. +- `Common/Utility/SCPinnedHosts.h` *(new)*: the baked-in (host, SPKI hash) constants. +- `Daemon/SCDaemonBlockMethods.m`: replace the "is block over?" check with the new three-piece gate. +- `Daemon/SCDaemon.m`: register a 30s checkpoint timer; manage the Piece-3 backoff state machine. +- `Daemon/SCDaemonProtocol.h` and `Common/SCXPCClient.{h,m}`: extend XPC payload with `BlockUnlockGate` fields. +- `TimerWindowController.{h,m}`: render the "waiting on internet" state. +- `SelfControlTests/`: tests for `SCBlockClock` (clock-change scenarios, reboot scenarios) and `SCTrustedTime` (pinning success, pinning failure, MITM rejection, host-down). + +## What does NOT change + +- The user interface for starting a block. +- The PF firewall mechanism or daemon installation. +- The XPC authorization model. +- Existing settings keys (we add, we don't migrate or remove). +- Block duration limits, blocklist format, or any other product behavior. + +## Testing strategy + +Unit-testable in `SelfControlTests/`: + +- **`SCBlockClock` tests:** simulate clock jumps forward, jumps backward, reboot (reset of monotonic + new boot UUID), and verify `elapsedSecondsAccumulated` only ever grows by the trusted delta. +- **`SCTrustedTime` tests:** spin up a local TLS server with a known cert. Verify: (a) request to a host with matching pinned SPKI succeeds; (b) same host but with a different cert chain is rejected; (c) host whose name resolves to the local server but with mismatched SPKI is rejected. +- **End-to-end manual test plan** (in QA notes, not automated): + 1. Start a 2-minute block. Run `sudo date 010100002030`. Verify block does not end. + 2. Start a block. Reboot. Verify block continues with correct remaining time. + 3. Start a block. Edit `/etc/hosts` to point `www.apple.com` at `127.0.0.1`. Verify block does not end at expiry; UI shows the "connect to internet" message. + 4. Start a block. Disconnect Wi-Fi at expiry. Verify UI shows the message; reconnect; verify block ends within ~30s. + +## Open questions + +None at design time. The implementation plan (next step) will need to choose: + +- Exact SPKI hashes for the pinned hosts and a rotation policy for when those keys change. +- Exact retry/backoff intervals for Piece 3. +- Whether to expose `lastNetworkErrorReason` in a hidden debug panel. + +These are tunable, not architectural. + +## Out of scope (explicit non-goals) + +- A second factor (PIN, password, biometric) at unlock — separate idea. +- Migrating away from the existing `BlockEndDate` field — we keep it for backward compatibility and as a third corroborating signal. +- Cross-device sync of block state — separate idea. +- Stopping the user from uninstalling SelfControl entirely — fundamentally out of scope for a local app. diff --git a/en.lproj/Localizable.strings b/en.lproj/Localizable.strings index cf1c4bc1..23e86c9c 100644 Binary files a/en.lproj/Localizable.strings and b/en.lproj/Localizable.strings differ