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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Common/SCXPCClient.h
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ NS_ASSUME_NONNULL_BEGIN
- (void)startBlockWithControllingUID:(uid_t)controllingUID blocklist:(NSArray<NSString*>*)blocklist isAllowlist:(BOOL)isAllowlist endDate:(NSDate*)endDate blockSettings:(NSDictionary*)blockSettings reply:(void(^)(NSError* error))reply;
- (void)updateBlocklist:(NSArray<NSString*>*)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

Expand Down
14 changes: 14 additions & 0 deletions Common/SCXPCClient.m
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,20 @@ - (void)updateBlocklist:(NSArray<NSString*>*)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) {
Expand Down
60 changes: 60 additions & 0 deletions Common/Utility/SCBlockClock.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
#import <Foundation/Foundation.h>

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<NSString*>*)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<NSString*>*)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
187 changes: 187 additions & 0 deletions Common/Utility/SCBlockClock.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
#import "SCBlockClock.h"
#import "SCSettings.h"
#import <mach/mach_time.h>
#import <sys/sysctl.h>

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];
}
Comment on lines +27 to +35
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Returning "unknown" on sysctlbyname failure can cause a spurious cross-boot credit.

If sysctlbyname fails at recordBlockStartWithDuration: (persisting bootSessionUUID = "unknown") but later succeeds at tickCheckpoint time (returning a real UUID), the mismatch on Line 89 will execute the cross-boot branch and credit MAX(0, wall-clock gap) toward elapsed time. That credit is fully attacker-controlled via sudo date between the failure and the next checkpoint. Reading kern.boottime is extremely unlikely to fail in practice, but the failure mode bypasses exactly the threat model this clock is built to resist.

Consider one of: (a) refusing to record a block start when the boot UUID is unavailable, (b) re-reading the UUID at recording time and asserting/logging on failure, or (c) treating "unknown" specially in tickCheckpoint so it doesn't trigger the cross-boot path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Common/Utility/SCBlockClock.m` around lines 19 - 27, currentBootSessionUUID
should not return the string @"unknown" because that value can later be treated
as a real bootSessionUUID and allow attacker-controlled cross-boot credit;
change currentBootSessionUUID to return nil on sysctlbyname failure (and still
honor sBootUUIDOverride), then update recordBlockStartWithDuration: to detect a
nil bootSessionUUID and refuse to persist/start a block (log/error/early-return)
rather than recording "unknown"; also add a defensive check in tickCheckpoint to
treat a nil or overridden sBootUUIDOverride consistently (avoid taking the
cross-boot branch when the stored bootSessionUUID or currentBootSessionUUID is
nil) so mismatches caused by transient read failures cannot be credited.


#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<NSString*>*)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<NSString*>*)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
4 changes: 4 additions & 0 deletions Common/Utility/SCBlockUtilities.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
6 changes: 6 additions & 0 deletions Common/Utility/SCBlockUtilities.m
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
#import "SCBlockUtilities.h"
#import "HostFileBlocker.h"
#import "PacketFilter.h"
#import "SCBlockClock.h"

@implementation SCBlockUtilities

Expand Down Expand Up @@ -61,6 +62,11 @@ + (BOOL)currentBlockIsExpired {
}
}

+ (BOOL)currentBlockIsTrulyExpired {
if (![self currentBlockIsExpired]) return NO;
return [SCBlockClock blockDurationHasElapsed];
}
Comment on lines +65 to +68
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Pre-upgrade / missing-checkpoint blocks may never be "truly expired".

SCBlockClock blockDurationHasElapsed returns NO when BlockTimekeeping is nil (Line 126 of SCBlockClock.m). Any block that exists in SCSettings without a corresponding BlockTimekeeping dictionary — e.g. an in-progress block that was started before this version was installed, or after a settings-store anomaly — will therefore have currentBlockIsTrulyExpired == NO indefinitely even after the wall-clock end date has long passed. The daemon will then refuse to tear down the firewall, stranding the user.

Consider either:

  • Lazily seeding BlockTimekeeping from BlockEndDate / start fields the first time the daemon observes a block without one (treat the block as having already accumulated now - BlockStartDate).
  • Treating "no BlockTimekeeping but wall-clock expired" as truly expired (assuming the legacy expiry check is sufficient when monotonic state is simply absent rather than tampered).

The right answer depends on threat-model intent for the upgrade path, but the current behavior is failure-closed in a way that hurts honest users.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Common/Utility/SCBlockUtilities.m` around lines 65 - 68, The current
implementation of currentBlockIsTrulyExpired returns NO when SCBlockClock's
blockDurationHasElapsed is NO (which happens if BlockTimekeeping is nil),
causing pre-upgrade blocks to never be torn down; modify
currentBlockIsTrulyExpired to treat a missing BlockTimekeeping as truly expired
when the wall-clock expiry has passed: keep the initial currentBlockIsExpired
check, then if [SCBlockClock blockDurationHasElapsed] is YES return YES,
otherwise detect that the BlockTimekeeping entry for the current block is absent
(or nil) and if the block’s legacy wall-clock fields (BlockEndDate or
BlockStartDate compared to now) indicate expiry, return YES; reference
currentBlockIsTrulyExpired, currentBlockIsExpired, SCBlockClock
blockDurationHasElapsed, BlockTimekeeping, BlockEndDate and BlockStartDate when
implementing this branch.


+ (BOOL)blockRulesFoundOnSystem {
return [PacketFilter blockFoundInPF] || [HostFileBlocker blockFoundInHostsFile];
}
Expand Down
15 changes: 15 additions & 0 deletions Common/Utility/SCPinnedHosts.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
#import <Foundation/Foundation.h>

NS_ASSUME_NONNULL_BEGIN

/// Baked-in pin list for the trusted-time HTTPS gate. Each entry is
/// @{ @"host": @"<hostname>", @"spki": @"<base64 SHA-256 of leaf SPKI>" }.
/// The quorum check requires 2 of 3 to succeed, so a single rotation
/// failure does not break the gate.
@interface SCPinnedHosts : NSObject

+ (NSArray<NSDictionary<NSString*, NSString*>*>*)hosts;

@end

NS_ASSUME_NONNULL_END
22 changes: 22 additions & 0 deletions Common/Utility/SCPinnedHosts.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
#import "SCPinnedHosts.h"

@implementation SCPinnedHosts

+ (NSArray<NSDictionary<NSString*, NSString*>*>*)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;
}
Comment on lines +5 to +20
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

❓ Verification inconclusive

Script executed:

#!/bin/bash
# Recompute SHA-256(base64) of SecKeyCopyExternalRepresentation()-equivalent
# raw public-key bytes for each pinned host and compare to the baked values.
set -euo pipefail

compute_pin() {
  local host="$1"
  # Get leaf cert
  local pem
  pem=$(echo | openssl s_client -servername "$host" -connect "${host}:443" -showcerts 2>/dev/null \
        | awk '/-BEGIN CERTIFICATE-/,/-END CERTIFICATE-/{print; if(/-END/) exit}')

  # Extract leaf public key in DER (this gives ASN.1 SPKI, NOT what the code hashes).
  # The code hashes SecKeyCopyExternalRepresentation output:
  #   RSA  -> PKCS#1 RSAPublicKey (modulus + exponent)
  #   EC   -> X9.63 uncompressed point (0x04 || X || Y)
  local key_alg
  key_alg=$(echo "$pem" | openssl x509 -noout -pubkey | openssl pkey -pubin -text -noout 2>/dev/null | head -1)

  echo "host=$host"
  echo "key_alg=$key_alg"
  if echo "$key_alg" | grep -qi 'RSA'; then
    # PKCS#1 RSAPublicKey DER:
    echo "$pem" | openssl x509 -noout -pubkey \
      | openssl rsa -pubin -RSAPublicKey_out -outform DER 2>/dev/null \
      | openssl dgst -sha256 -binary | base64
  else
    # EC X9.63 uncompressed point: extract the BIT STRING from SPKI minus header.
    # Approximation: use openssl ec -pubin -outform DER then strip the SPKI prefix.
    echo "(EC pin recomputation requires X9.63 raw point — recompute on a Mac with SecKeyCopyExternalRepresentation)"
  fi
  echo "---"
}

compute_pin www.apple.com
compute_pin www.google.com
compute_pin www.cloudflare.com

Repository: SelfControlApp/selfcontrol


Repository: SelfControlApp/selfcontrol
Exit code: 127


Script executed:

# Find references to SCPinnedHosts and understand the unlock/gate logic
rg -l "SCPinnedHosts" --type objc --type swift

Repository: SelfControlApp/selfcontrol


Repository: SelfControlApp/selfcontrol
Exit code: 0

stdout:

SelfControlTests/SCTrustedTimeTests.m
Common/Utility/SCTrustedTime.m
Common/Utility/SCPinnedHosts.m
Common/Utility/SCPinnedHosts.h


Script executed:

# Search for usage of the hosts method and understand the verification flow
rg "hosts\]" --type objc -A 3 -B 3 | head -100

Repository: SelfControlApp/selfcontrol


Repository: SelfControlApp/selfcontrol
Exit code: 0

stdout:

SelfControlTests/SCTrustedTimeTests.m-
SelfControlTests/SCTrustedTimeTests.m-- (void)testFetchFromAppleWithCorrectPinReturnsDate {
SelfControlTests/SCTrustedTimeTests.m-    XCTestExpectation* exp = [self expectationWithDescription: @"apple"];
SelfControlTests/SCTrustedTimeTests.m:    NSDictionary* apple = [SCPinnedHosts hosts][0]; // assumes apple is index 0
SelfControlTests/SCTrustedTimeTests.m-    [SCTrustedTime fetchTimeFromHost: apple[@"host"]
SelfControlTests/SCTrustedTimeTests.m-                        expectedSPKI: apple[@"spki"]
SelfControlTests/SCTrustedTimeTests.m-                             timeout: 10.0
--
Common/Utility/SCTrustedTime.m-}
Common/Utility/SCTrustedTime.m-
Common/Utility/SCTrustedTime.m-+ (void)verifyTimeIsAfter:(NSDate*)threshold completion:(SCTrustedTimeQuorumCompletion)completion {
Common/Utility/SCTrustedTime.m:    NSArray* hosts = [SCPinnedHosts hosts];
Common/Utility/SCTrustedTime.m-    dispatch_group_t group = dispatch_group_create();
Common/Utility/SCTrustedTime.m-    NSMutableArray<NSDate*>* results = [NSMutableArray array];
Common/Utility/SCTrustedTime.m-    NSLock* lock = [NSLock new];


Script executed:

# Look for quorum logic or multiple host verification
rg -i "quorum|verify|pin" --type objc --type swift | head -50

Repository: SelfControlApp/selfcontrol


Repository: SelfControlApp/selfcontrol
Exit code: 0

stdout:

Sparkle.framework/Versions/A/Headers/SUUpdater.h: This will find updates that the user has opted into skipping.
Sparkle.framework/Versions/A/Headers/SUUpdater.h: This will not find updates that the user has opted into skipping.
SelfControlTests/SCUtilityTests.m:    NSMutableDictionary* tk = [[s valueForKey: @"BlockTimekeeping"] mutableCopy];
SelfControlTests/SCUtilityTests.m:    [s setValue: tk forKey: @"BlockTimekeeping"];
SelfControlTests/SCUtilityTests.m:    [s setValue: nil forKey: @"BlockTimekeeping"];
SelfControlTests/SCTrustedTimeTests.m:`#import` "SCPinnedHosts.h"
SelfControlTests/SCTrustedTimeTests.m:- (void)testFetchFromAppleWithCorrectPinReturnsDate {
SelfControlTests/SCTrustedTimeTests.m:    NSDictionary* apple = [SCPinnedHosts hosts][0]; // assumes apple is index 0
SelfControlTests/SCTrustedTimeTests.m:- (void)testFetchWithWrongPinFails {
SelfControlTests/SCTrustedTimeTests.m:    XCTestExpectation* exp = [self expectationWithDescription: @"badpin"];
SelfControlTests/SCTrustedTimeTests.m:- (void)testVerifyAfterPastThresholdSucceeds {
SelfControlTests/SCTrustedTimeTests.m:    XCTestExpectation* exp = [self expectationWithDescription: @"quorum"];
SelfControlTests/SCTrustedTimeTests.m:    [SCTrustedTime verifyTimeIsAfter: past
SelfControlTests/SCTrustedTimeTests.m:- (void)testVerifyAfterFutureThresholdFails {
SelfControlTests/SCTrustedTimeTests.m:    [SCTrustedTime verifyTimeIsAfter: future
SelfControlTests/SCBlockClockTests.m:    [[SCSettings sharedSettings] setValue: nil forKey: @"BlockTimekeeping"];
SelfControlTests/SCBlockClockTests.m:- (void)testRecordBlockStartPopulatesTimekeepingDict {
SelfControlTests/SCBlockClockTests.m:    NSDictionary* tk = [[SCSettings sharedSettings] valueForKey: @"BlockTimekeeping"];
SelfControlTests/SCBlockClockTests.m:    NSDictionary* first = [[[SCSettings sharedSettings] valueForKey: @"BlockTimekeeping"] copy];
SelfControlTests/SCBlockClockTests.m:    NSDictionary* second = [[SCSettings sharedSettings] valueForKey: @"BlockTimekeeping"];
SelfControlTests/SCBlockClockTests.m:    NSMutableDictionary* tk = [[[SCSettings sharedSettings] valueForKey: @"BlockTimekeeping"] mutableCopy];
SelfControlTests/SCBlockClockTests.m:    [[SCSettings sharedSettings] setValue: tk forKey: @"BlockTimekeeping"];
SelfControlTests/SCBlockClockTests.m:    NSMutableDictionary* tk = [[[SCSettings sharedSettings] valueForKey: @"BlockTimekeeping"] mutableCopy];
SelfControlTests/SCBlockClockTests.m:    [[SCSettings sharedSettings] setValue: tk forKey: @"BlockTimekeeping"];
SelfControlTests/SCBlockClockTests.m:    NSMutableDictionary* tk = [[[SCSettings sharedSettings] valueForKey: @"BlockTimekeeping"] mutableCopy];
SelfControlTests/SCBlockClockTests.m:    [[SCSettings sharedSettings] setValue: tk forKey: @"BlockTimekeeping"];
SelfControlTests/SCBlockClockTests.m:    NSMutableDictionary* tk = [[[SCSettings sharedSettings] valueForKey: @"BlockTimekeeping"] mutableCopy];
SelfControlTests/SCBlockClockTests.m:    [[SCSettings sharedSettings] setValue: tk forKey: @"BlockTimekeeping"];
SCConstants.m:                      @"Ping",
SCConstants.m:            @"VerifyInternetConnection": `@YES`,
Daemon/SCDaemonBlockMethods.m:static NSTimeInterval const kVerifyBackoffs[] = { 10.0, 30.0, 60.0, 120.0, 120.0 };
Daemon/SCDaemonBlockMethods.m:static const NSUInteger kVerifyBackoffsCount = sizeof(kVerifyBackoffs) / sizeof(kVerifyBackoffs[0]);
Daemon/SCDaemonBlockMethods.m:    [SCTrustedTime verifyTimeIsAfter: endDate completion:^(BOOL ok, NSDate* med, NSError* err) {
Daemon/SCDaemonBlockMethods.m:        NSTimeInterval backoff = kVerifyBackoffs[ MIN(attempt, kVerifyBackoffsCount - 1) ];
Common/Utility/SCTrustedTime.m:`#import` "SCPinnedHosts.h"
Common/Utility/SCTrustedTime.m:`@interface` SCTrustedTimePinningDelegate : NSObject <NSURLSessionDelegate>
Common/Utility/SCTrustedTime.m:`@implementation` SCTrustedTimePinningDelegate
Common/Utility/SCTrustedTime.m:    SCTrustedTimePinningDelegate* d = [SCTrustedTimePinningDelegate new];
Common/Utility/SCTrustedTime.m:+ (void)verifyTimeIsAfter:(NSDate*)threshold completion:(SCTrustedTimeQuorumCompletion)completion {
Common/Utility/SCTrustedTime.m:    NSArray* hosts = [SCPinnedHosts hosts];
Common/Utility/SCTrustedTime.m:                                                userInfo: @{NSLocalizedDescriptionKey: @"Quorum not reached"}]);
Common/Utility/SCTrustedTime.m:                                                userInfo: @{NSLocalizedDescriptionKey: @"Pinned hosts disagree"}]);
Common/Utility/SCPinnedHosts.m:`#import` "SCPinnedHosts.h"
Common/Utility/SCPinnedHosts.m:`@implementation` SCPinnedHosts
Common/Utility/SCPinnedHosts.m:        // Pins are SHA-256 (base64) of SecKeyCopyExternalRepresentation() output
Common/Utility/SCPinnedHosts.m:        // To recompute, see the SCTrustedTime pinning delegate in SCTrustedTime.m.
Common/Utility/SCPinnedHosts.h:/// Baked-in pin list for the trusted-time HTTPS gate. Each entry is
Common/Utility/SCPinnedHosts.h:/// The quorum check requires 2 of 3 to succeed, so a single rotation
Common/Utility/SCPinnedHosts.h:`@interface` SCPinnedHosts : NSObject
Common/Utility/SCMigrationUtilities.m:        NSLog(@"Skipping copy to defaults because migration to V4 was already completed.");


Script executed:

# Find where the unlock gate logic is implemented
fd -name "*.m" -o -name "*.swift" | xargs rg -l "unlock|gate" | head -20

Repository: SelfControlApp/selfcontrol


Repository: SelfControlApp/selfcontrol
Exit code: 0

stdout:

fr.lproj/PreferencesAdvancedViewController.strings
docs/superpowers/specs/2026-05-14-tamper-resistant-timing-design.md
docs/superpowers/plans/2026-05-14-tamper-resistant-timing.md
TimerWindowController.m
TimerWindowController.h
Sparkle.framework/Versions/A/_CodeSignature/CodeResources
Sparkle.framework/Versions/A/Headers/Sparkle.h
Sparkle.framework/Versions/A/Headers/SUUpdaterDelegate.h
Sparkle.framework/Versions/A/Headers/SUUpdater.h
Sparkle.framework/Versions/A/Headers/SPUDownloaderDelegate.h
Sparkle.framework/Versions/A/Headers/SPUDownloader.h
SelfControl.xcodeproj/project.pbxproj
SelfControl Killer/fr.lproj/MainMenu.xib
SelfControl Killer/Base.lproj/MainMenu.xib
SelfControl Killer/AppDelegate.m
SelfControl Killer/AppDelegate.h
DomainListWindowController.m
Daemon/SCDaemonXPC.m
Daemon/SCDaemonProtocol.h
Daemon/SCDaemonBlockMethods.m

stderr:

error: Found argument '-n' which wasn't expected, or isn't valid in this context

  If you tried to supply '-n' as a value rather than a flag, use '-- -n'

Usage: fd [OPTIONS] [pattern] [path]...

For more information try '--help'


Script executed:

cat -n Common/Utility/SCTrustedTime.m | head -150

Repository: SelfControlApp/selfcontrol


Repository: SelfControlApp/selfcontrol
Exit code: 0

stdout:

     1	`#import` "SCTrustedTime.h"
     2	`#import` "SCPinnedHosts.h"
     3	`#import` <CommonCrypto/CommonDigest.h>
     4	`#import` <Security/Security.h>
     5	
     6	`@interface` SCTrustedTimePinningDelegate : NSObject <NSURLSessionDelegate>
     7	`@property` (copy) NSString* expectedSPKI;
     8	`@end`
     9	
    10	`@implementation` SCTrustedTimePinningDelegate
    11	
    12	- (void)URLSession:(NSURLSession*)session
    13	didReceiveChallenge:(NSURLAuthenticationChallenge*)challenge
    14	 completionHandler:(void(^)(NSURLSessionAuthChallengeDisposition, NSURLCredential* _Nullable))ch
    15	{
    16	    if (![challenge.protectionSpace.authenticationMethod isEqualToString: NSURLAuthenticationMethodServerTrust]) {
    17	        ch(NSURLSessionAuthChallengeCancelAuthenticationChallenge, nil);
    18	        return;
    19	    }
    20	    SecTrustRef trust = challenge.protectionSpace.serverTrust;
    21	    if (trust == NULL) { ch(NSURLSessionAuthChallengeCancelAuthenticationChallenge, nil); return; }
    22	
    23	    CFIndex count = SecTrustGetCertificateCount(trust);
    24	    BOOL matched = NO;
    25	    for (CFIndex i = 0; i < count && !matched; i++) {
    26	        SecCertificateRef cert = SecTrustGetCertificateAtIndex(trust, i);
    27	        SecKeyRef pubkey = SecCertificateCopyKey(cert);
    28	        if (pubkey == NULL) continue;
    29	        CFErrorRef err = NULL;
    30	        CFDataRef der = SecKeyCopyExternalRepresentation(pubkey, &err);
    31	        CFRelease(pubkey);
    32	        if (der == NULL) continue;
    33	        unsigned char digest[CC_SHA256_DIGEST_LENGTH];
    34	        CC_SHA256(CFDataGetBytePtr(der), (CC_LONG)CFDataGetLength(der), digest);
    35	        NSData* digestData = [NSData dataWithBytes: digest length: CC_SHA256_DIGEST_LENGTH];
    36	        NSString* b64 = [digestData base64EncodedStringWithOptions: 0];
    37	        CFRelease(der);
    38	        if ([b64 isEqualToString: self.expectedSPKI]) matched = YES;
    39	    }
    40	
    41	    if (matched) {
    42	        ch(NSURLSessionAuthChallengeUseCredential, [NSURLCredential credentialForTrust: trust]);
    43	    } else {
    44	        ch(NSURLSessionAuthChallengeCancelAuthenticationChallenge, nil);
    45	    }
    46	}
    47	
    48	`@end`
    49	
    50	`@implementation` SCTrustedTime
    51	
    52	+ (NSDateFormatter*)imfFormatter {
    53	    static NSDateFormatter* f = nil;
    54	    static dispatch_once_t once;
    55	    dispatch_once(&once, ^{
    56	        f = [[NSDateFormatter alloc] init];
    57	        f.locale = [NSLocale localeWithLocaleIdentifier: @"en_US_POSIX"];
    58	        f.timeZone = [NSTimeZone timeZoneWithAbbreviation: @"GMT"];
    59	        f.dateFormat = @"EEE, dd MMM yyyy HH:mm:ss zzz";
    60	    });
    61	    return f;
    62	}
    63	
    64	+ (NSDate*)parseHTTPDateHeader:(NSString*)header {
    65	    if (header.length == 0) return nil;
    66	    return [[self imfFormatter] dateFromString: header];
    67	}
    68	
    69	+ (void)fetchTimeFromHost:(NSString*)host
    70	             expectedSPKI:(NSString*)spki
    71	                  timeout:(NSTimeInterval)timeoutSeconds
    72	               completion:(SCTrustedTimeCompletion)completion
    73	{
    74	    SCTrustedTimePinningDelegate* d = [SCTrustedTimePinningDelegate new];
    75	    d.expectedSPKI = spki;
    76	    NSURLSessionConfiguration* cfg = [NSURLSessionConfiguration ephemeralSessionConfiguration];
    77	    cfg.timeoutIntervalForRequest = timeoutSeconds;
    78	    NSURLSession* session = [NSURLSession sessionWithConfiguration: cfg delegate: d delegateQueue: nil];
    79	
    80	    NSURL* url = [NSURL URLWithString: [NSString stringWithFormat: @"https://%@/", host]];
    81	    NSMutableURLRequest* req = [NSMutableURLRequest requestWithURL: url];
    82	    req.HTTPMethod = @"HEAD";
    83	
    84	    [[session dataTaskWithRequest: req completionHandler:^(NSData* data, NSURLResponse* resp, NSError* error) {
    85	        [session finishTasksAndInvalidate];
    86	        if (error) { completion(nil, error); return; }
    87	        NSHTTPURLResponse* http = (NSHTTPURLResponse*)resp;
    88	        NSString* dateStr = http.allHeaderFields[@"Date"];
    89	        NSDate* parsed = [SCTrustedTime parseHTTPDateHeader: dateStr];
    90	        if (parsed == nil) {
    91	            completion(nil, [NSError errorWithDomain: @"SCTrustedTime" code: 1
    92	                                            userInfo: @{NSLocalizedDescriptionKey: @"No/invalid Date header"}]);
    93	            return;
    94	        }
    95	        completion(parsed, nil);
    96	    }] resume];
    97	}
    98	
    99	+ (void)verifyTimeIsAfter:(NSDate*)threshold completion:(SCTrustedTimeQuorumCompletion)completion {
   100	    NSArray* hosts = [SCPinnedHosts hosts];
   101	    dispatch_group_t group = dispatch_group_create();
   102	    NSMutableArray<NSDate*>* results = [NSMutableArray array];
   103	    NSLock* lock = [NSLock new];
   104	
   105	    for (NSDictionary* hp in hosts) {
   106	        dispatch_group_enter(group);
   107	        [self fetchTimeFromHost: hp[@"host"]
   108	                   expectedSPKI: hp[@"spki"]
   109	                        timeout: 8.0
   110	                     completion:^(NSDate* d, NSError* e) {
   111	            if (d != nil) {
   112	                [lock lock]; [results addObject: d]; [lock unlock];
   113	            }
   114	            dispatch_group_leave(group);
   115	        }];
   116	    }
   117	
   118	    dispatch_group_notify(group, dispatch_get_global_queue(QOS_CLASS_DEFAULT, 0), ^{
   119	        if (results.count < 2) {
   120	            completion(NO, nil, [NSError errorWithDomain: @"SCTrustedTime" code: 2
   121	                                                userInfo: @{NSLocalizedDescriptionKey: @"Quorum not reached"}]);
   122	            return;
   123	        }
   124	        NSArray* sorted = [results sortedArrayUsingSelector: `@selector`(compare:)];
   125	        NSDate* min = sorted.firstObject;
   126	        NSDate* max = sorted.lastObject;
   127	        if ([max timeIntervalSinceDate: min] > 300.0) {
   128	            completion(NO, nil, [NSError errorWithDomain: @"SCTrustedTime" code: 3
   129	                                                userInfo: @{NSLocalizedDescriptionKey: @"Pinned hosts disagree"}]);
   130	            return;
   131	        }
   132	        NSDate* median = sorted[sorted.count / 2];
   133	        BOOL ok = [median compare: threshold] != NSOrderedAscending;
   134	        completion(ok, median, nil);
   135	    });
   136	}
   137	
   138	`@end`


Script executed:

cat -n Common/Utility/SCPinnedHosts.h

Repository: SelfControlApp/selfcontrol


Repository: SelfControlApp/selfcontrol
Exit code: 0

stdout:

     1	`#import` <Foundation/Foundation.h>
     2	
     3	NS_ASSUME_NONNULL_BEGIN
     4	
     5	/// Baked-in pin list for the trusted-time HTTPS gate. Each entry is
     6	/// @{ @"host": @"<hostname>", @"spki": @"<base64 SHA-256 of leaf SPKI>" }.
     7	/// The quorum check requires 2 of 3 to succeed, so a single rotation
     8	/// failure does not break the gate.
     9	`@interface` SCPinnedHosts : NSObject
    10	
    11	+ (NSArray<NSDictionary<NSString*, NSString*>*>*)hosts;
    12	
    13	`@end`
    14	
    15	NS_ASSUME_NONNULL_END


Script executed:

# Look at where unlock/gate logic calls verifyTimeIsAfter
rg -A 10 "verifyTimeIsAfter" Daemon/SCDaemonBlockMethods.m

Repository: SelfControlApp/selfcontrol


Repository: SelfControlApp/selfcontrol
Exit code: 0

stdout:

    [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];
            [SCSentry addBreadcrumb: @"Daemon performed verified unlock" category: @"daemon"];
            [[SCDaemon sharedDaemon] stopCheckupTimer];
            [[SCDaemon sharedDaemon] stopCheckpointTimer];
            return;


Script executed:

rg -i "rotate|fallback|backup|next.*pin|alternate" --type objc --type swift | grep -v "node_modules" | head -30

Repository: SelfControlApp/selfcontrol


Repository: SelfControlApp/selfcontrol
Exit code: 0

stdout:

Daemon/SCDaemonBlockMethods.m:            NSLog(@"WARNING: Error removing host file block.  Attempting to restore backup.");
Daemon/SCDaemonBlockMethods.m:            if([hostFileBlockerSet restoreBackupHostsFile])
Daemon/SCDaemonBlockMethods.m:                NSLog(@"INFO: Host file backup restored.");
Daemon/SCDaemonBlockMethods.m:                NSLog(@"ERROR: Host file backup could not be restored.  This may result in a permanent block.");
Daemon/SCDaemonBlockMethods.m:        // Get rid of the backup file since we're about to make a new one.
Daemon/SCDaemonBlockMethods.m:        [hostFileBlockerSet deleteBackupHostsFile];
Block Management/HostFileBlockerSet.m:    NSArray<NSString*>* commonBackupHostFilePaths = @[
Block Management/HostFileBlockerSet.m:    NSMutableArray* hostFileBlockers = [NSMutableArray arrayWithCapacity: commonBackupHostFilePaths.count + 1];
Block Management/HostFileBlockerSet.m:    for (NSString* path in commonBackupHostFilePaths) {
Block Management/HostFileBlockerSet.m:            NSLog(@"INFO: found backup VPN host file at %@", path);
Block Management/HostFileBlockerSet.m:- (BOOL)deleteBackupHostsFile {
Block Management/HostFileBlockerSet.m:        ret = ret && [blocker deleteBackupHostsFile];
Block Management/HostFileBlockerSet.m:- (BOOL)createBackupHostsFile {
Block Management/HostFileBlockerSet.m:        ret = ret && [blocker createBackupHostsFile];
Block Management/HostFileBlockerSet.m:- (BOOL)restoreBackupHostsFile {
Block Management/HostFileBlockerSet.m:        ret = ret && [blocker restoreBackupHostsFile];
Block Management/HostFileBlocker.h:- (BOOL)deleteBackupHostsFile;
Block Management/HostFileBlocker.h:- (BOOL)createBackupHostsFile;
Block Management/HostFileBlocker.h:- (BOOL)restoreBackupHostsFile;
Block Management/BlockManager.m:        [hostBlockerSet createBackupHostsFile];
Block Management/BlockManager.m:			NSLog(@"WARNING: Error removing hostfile block.  Attempting to restore host file backup.");
Block Management/BlockManager.m:			[hostBlockerSet restoreBackupHostsFile];
Block Management/BlockManager.m:			NSLog(@"ERROR: Host file backup could not be restored.  This may result in a permanent block.");
Block Management/BlockManager.m:	[hostBlockerSet deleteBackupHostsFile];
Block Management/BlockManager.m:			NSLog(@"WARNING: Error removing hostfile block.  Attempting to restore host file backup.");
Block Management/BlockManager.m:			[hostBlockerSet restoreBackupHostsFile];
Block Management/BlockManager.m:			NSLog(@"ERROR: Host file backup could not be restored.  This may result in a permanent block.");
Block Management/HostFileBlocker.m:- (NSString*)backupHostFilePath {
Block Management/HostFileBlocker.m:- (BOOL)createBackupHostsFile {
Block Management/HostFileBlocker.m:	[self deleteBackupHostsFile];


Script executed:

# Check the test to see if it actually validates the pins work
cat -n SelfControlTests/SCTrustedTimeTests.m

Repository: SelfControlApp/selfcontrol


Repository: SelfControlApp/selfcontrol
Exit code: 0

stdout:

     1	`#import` <XCTest/XCTest.h>
     2	`#import` "SCTrustedTime.h"
     3	`#import` "SCPinnedHosts.h"
     4	
     5	`@interface` SCTrustedTimeTests : XCTestCase
     6	`@end`
     7	
     8	`@implementation` SCTrustedTimeTests
     9	
    10	- (void)testParseValidIMFFixdate {
    11	    NSDate* d = [SCTrustedTime parseHTTPDateHeader: @"Tue, 14 May 2026 12:00:00 GMT"];
    12	    XCTAssertNotNil(d);
    13	    NSDateComponents* c = [[NSCalendar calendarWithIdentifier: NSCalendarIdentifierGregorian]
    14	                            componentsInTimeZone: [NSTimeZone timeZoneWithAbbreviation: @"GMT"]
    15	                            fromDate: d];
    16	    XCTAssertEqual(c.year, 2026);
    17	    XCTAssertEqual(c.month, 5);
    18	    XCTAssertEqual(c.day, 14);
    19	    XCTAssertEqual(c.hour, 12);
    20	}
    21	
    22	- (void)testParseGarbageReturnsNil {
    23	    XCTAssertNil([SCTrustedTime parseHTTPDateHeader: @"not a date"]);
    24	    XCTAssertNil([SCTrustedTime parseHTTPDateHeader: @""]);
    25	    XCTAssertNil([SCTrustedTime parseHTTPDateHeader: nil]);
    26	}
    27	
    28	- (void)testFetchFromAppleWithCorrectPinReturnsDate {
    29	    XCTestExpectation* exp = [self expectationWithDescription: @"apple"];
    30	    NSDictionary* apple = [SCPinnedHosts hosts][0]; // assumes apple is index 0
    31	    [SCTrustedTime fetchTimeFromHost: apple[@"host"]
    32	                        expectedSPKI: apple[@"spki"]
    33	                             timeout: 10.0
    34	                          completion:^(NSDate* d, NSError* e) {
    35	        XCTAssertNil(e);
    36	        XCTAssertNotNil(d);
    37	        XCTAssertLessThan(ABS([d timeIntervalSinceNow]), 120.0);
    38	        [exp fulfill];
    39	    }];
    40	    [self waitForExpectations: @[exp] timeout: 15.0];
    41	}
    42	
    43	- (void)testFetchWithWrongPinFails {
    44	    XCTestExpectation* exp = [self expectationWithDescription: @"badpin"];
    45	    [SCTrustedTime fetchTimeFromHost: @"www.apple.com"
    46	                        expectedSPKI: @"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
    47	                             timeout: 10.0
    48	                          completion:^(NSDate* d, NSError* e) {
    49	        XCTAssertNotNil(e);
    50	        XCTAssertNil(d);
    51	        [exp fulfill];
    52	    }];
    53	    [self waitForExpectations: @[exp] timeout: 15.0];
    54	}
    55	
    56	- (void)testVerifyAfterPastThresholdSucceeds {
    57	    XCTestExpectation* exp = [self expectationWithDescription: @"quorum"];
    58	    NSDate* past = [NSDate dateWithTimeIntervalSinceNow: -3600];
    59	    [SCTrustedTime verifyTimeIsAfter: past
    60	                          completion:^(BOOL ok, NSDate* med, NSError* err) {
    61	        XCTAssertTrue(ok);
    62	        XCTAssertNotNil(med);
    63	        [exp fulfill];
    64	    }];
    65	    [self waitForExpectations: @[exp] timeout: 25.0];
    66	}
    67	
    68	- (void)testVerifyAfterFutureThresholdFails {
    69	    XCTestExpectation* exp = [self expectationWithDescription: @"future"];
    70	    NSDate* future = [NSDate dateWithTimeIntervalSinceNow: 86400 * 365 * 10];
    71	    [SCTrustedTime verifyTimeIsAfter: future
    72	                          completion:^(BOOL ok, NSDate* med, NSError* err) {
    73	        XCTAssertFalse(ok);
    74	        [exp fulfill];
    75	    }];
    76	    [self waitForExpectations: @[exp] timeout: 25.0];
    77	}
    78	
    79	`@end`


Hardcoded leaf-cert pins without rotation strategy will lock users on routine certificate rotation.

These three hosts rotate leaf certificates regularly. The quorum check (2 of 3) tolerates a single host failure, but simultaneous rotation on two hosts — or one rotation coinciding with any other outage — will hard-lock legitimate users with no recovery path:

  • Multiple acceptable pins per host (current + staged next) enable rotation without a release cycle.
  • Intermediate-level pinning (GTS, DigiCert, Let's Encrypt roots) lasts years, not months, and still prevents MITM.
  • Signed, out-of-band pin updates unblock users on older builds without requiring a recompile.

Verify each pin is current against the live leaf certificates before merge. The code hashes SecKeyCopyExternalRepresentation() output (raw public-key bytes, not the full SPKI) and checks all certificates in the trust chain, so pins must match the actual leaf keys deployed today.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Common/Utility/SCPinnedHosts.m` around lines 5 - 20, The hosts method in
SCPinnedHosts.m currently returns a single hardcoded leaf SPKI pin per host
which will break on routine rotations; update SCPinnedHosts + the hosts method
to accept multiple pins per host (array of current + staged leaf pins and/or
intermediate/root SPKI pins), and implement/consume a rotation-friendly strategy
such as: include long-lived intermediate/root pins alongside current leaf pins,
allow multiple acceptable pins when checking the trust chain, and add support
for signed out-of-band pin updates (fallback mechanism) so older builds can
receive new pins; also verify and replace the three provided pin strings by
recomputing SecKeyCopyExternalRepresentation() on the live leaf certificates
before merging.


@end
30 changes: 30 additions & 0 deletions Common/Utility/SCTrustedTime.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
#import <Foundation/Foundation.h>

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://<host>/ 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;
Comment on lines +14 to +19
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Misleading "SPKI" wording in the public API – this is NOT a SubjectPublicKeyInfo hash.

The implementation hashes the output of SecKeyCopyExternalRepresentation() (raw key bytes: PKCS#1 RSA modulus+exponent or X9.63 EC point), as confirmed by the comment in SCPinnedHosts.m. That is not an RFC 5280 SubjectPublicKeyInfo digest, and anyone recomputing pins from openssl x509 -pubkey ... | openssl pkey -pubin -outform DER | sha256sum will get a different value and bake in pins that never match. Please correct the doc comment, and consider renaming the parameter (e.g. expectedPublicKeyHash) so the public surface matches what the code actually compares.

📝 Suggested wording fix
-/// Fetches https://<host>/ HEAD and returns the time from the Date: header IFF
-/// the server's leaf SubjectPublicKeyInfo SHA-256 (base64) matches expectedSPKI.
+/// Fetches https://<host>/ HEAD and returns the time from the Date: header IFF
+/// SHA-256 (base64) of `SecKeyCopyExternalRepresentation()` for some certificate
+/// in the server's chain matches `expectedPublicKeyHash`. NOTE: this is the raw
+/// public-key bytes (RSA modulus+exponent or EC point), NOT the ASN.1 SPKI hash
+/// produced by typical `openssl ... -pubkey` recipes.
 + (void)fetchTimeFromHost:(NSString*)host
-             expectedSPKI:(NSString*)spki
+    expectedPublicKeyHash:(NSString*)keyHash
                   timeout:(NSTimeInterval)timeoutSeconds
                completion:(SCTrustedTimeCompletion)completion;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Common/Utility/SCTrustedTime.h` around lines 14 - 19, The public API and doc
comment for fetchTimeFromHost:expectedSPKI:timeout:completion are misleading:
the implementation hashes SecKeyCopyExternalRepresentation() (raw public key
bytes), not an RFC5280 SubjectPublicKeyInfo digest. Change the doc comment to
state that the method compares the SHA-256 (base64) of the public key's raw
external representation, rename the parameter expectedSPKI to
expectedPublicKeyHash (or similar) to reflect this, and update all callers/tests
to use the new name and the correct hash computation method so pins computed via
openssl x509 -pubkey ... | openssl pkey -pubin -outform DER | sha256sum no
longer mismatch.


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
Loading