Log messages + OTP.
[IMPROVED] MP-15: Audit and improve log messages. [ADDED] If an element's counter is 0, generate a time-based OTP instead. The OTP changes every 5 minutes.
This commit is contained in:
parent
941b428cfc
commit
301366f1f1
2
External/Pearl
vendored
2
External/Pearl
vendored
@ -1 +1 @@
|
|||||||
Subproject commit d247edba08f70994bb8b0cb50413aedfa963cacd
|
Subproject commit bbb92ad1957b17d50aaa44e124fb5bacef05da10
|
@ -38,7 +38,7 @@ static NSDictionary *keyHashQuery() {
|
|||||||
|
|
||||||
- (void)forgetKey {
|
- (void)forgetKey {
|
||||||
|
|
||||||
dbg(@"Deleting key and hash from key chain.");
|
inf(@"Deleting key and hash from keychain.");
|
||||||
[PearlKeyChain deleteItemForQuery:keyQuery()];
|
[PearlKeyChain deleteItemForQuery:keyQuery()];
|
||||||
[PearlKeyChain deleteItemForQuery:keyHashQuery()];
|
[PearlKeyChain deleteItemForQuery:keyHashQuery()];
|
||||||
|
|
||||||
@ -57,13 +57,12 @@ static NSDictionary *keyHashQuery() {
|
|||||||
|
|
||||||
if ([[MPConfig get].saveKey boolValue]) {
|
if ([[MPConfig get].saveKey boolValue]) {
|
||||||
// Key is stored in keychain. Load it.
|
// Key is stored in keychain. Load it.
|
||||||
dbg(@"Loading key from key chain.");
|
|
||||||
[self updateKey:[PearlKeyChain dataOfItemForQuery:keyQuery()]];
|
[self updateKey:[PearlKeyChain dataOfItemForQuery:keyQuery()]];
|
||||||
dbg(@" -> Key %@.", self.key? @"found": @"NOT found");
|
inf(@"Looking for key in keychain: %@.", self.key? @"found": @"missing");
|
||||||
} else {
|
} else {
|
||||||
// Key should not be stored in keychain. Delete it.
|
// Key should not be stored in keychain. Delete it.
|
||||||
if ([PearlKeyChain deleteItemForQuery:keyQuery()] != errSecItemNotFound)
|
if ([PearlKeyChain deleteItemForQuery:keyQuery()] != errSecItemNotFound)
|
||||||
dbg(@"Deleted key from key chain.");
|
inf(@"Removed key from keychain.");
|
||||||
#ifdef TESTFLIGHT_SDK_VERSION
|
#ifdef TESTFLIGHT_SDK_VERSION
|
||||||
[TestFlight passCheckpoint:MPTestFlightCheckpointMPUnstored];
|
[TestFlight passCheckpoint:MPTestFlightCheckpointMPUnstored];
|
||||||
#endif
|
#endif
|
||||||
@ -72,19 +71,18 @@ static NSDictionary *keyHashQuery() {
|
|||||||
|
|
||||||
- (BOOL)tryMasterPassword:(NSString *)tryPassword {
|
- (BOOL)tryMasterPassword:(NSString *)tryPassword {
|
||||||
|
|
||||||
NSData *keyHash = [PearlKeyChain dataOfItemForQuery:keyHashQuery()];
|
|
||||||
dbg(@"Key hash %@.", keyHash? @"known": @"NOT known");
|
|
||||||
|
|
||||||
if (![tryPassword length])
|
if (![tryPassword length])
|
||||||
return NO;
|
return NO;
|
||||||
|
|
||||||
NSData *tryKey = keyForPassword(tryPassword);
|
NSData *tryKey = keyForPassword(tryPassword);
|
||||||
NSData *tryKeyHash = keyHashForKey(tryKey);
|
NSData *tryKeyHash = keyHashForKey(tryKey);
|
||||||
|
NSData *keyHash = [PearlKeyChain dataOfItemForQuery:keyHashQuery()];
|
||||||
|
inf(@"Key hash known? %@.", keyHash? @"YES": @"NO");
|
||||||
if (keyHash)
|
if (keyHash)
|
||||||
// A key hash is known -> a key is set.
|
// A key hash is known -> a key is set.
|
||||||
// Make sure the user's entered key matches it.
|
// Make sure the user's entered key matches it.
|
||||||
if (![keyHash isEqual:tryKeyHash]) {
|
if (![keyHash isEqual:tryKeyHash]) {
|
||||||
dbg(@"Key phrase hash mismatch. Expected: %@, answer: %@.", keyHash, tryKeyHash);
|
wrn(@"Key ID mismatch. Expected: %@, answer: %@.", [keyHash encodeHex], [tryKeyHash encodeHex]);
|
||||||
|
|
||||||
#ifdef TESTFLIGHT_SDK_VERSION
|
#ifdef TESTFLIGHT_SDK_VERSION
|
||||||
[TestFlight passCheckpoint:MPTestFlightCheckpointMPMismatch];
|
[TestFlight passCheckpoint:MPTestFlightCheckpointMPMismatch];
|
||||||
@ -115,24 +113,30 @@ static NSDictionary *keyHashQuery() {
|
|||||||
self.keyHash = keyHashForKey(self.key);
|
self.keyHash = keyHashForKey(self.key);
|
||||||
self.keyID = [self.keyHash encodeHex];
|
self.keyID = [self.keyHash encodeHex];
|
||||||
|
|
||||||
dbg(@"Updating key ID to: %@.", self.keyID);
|
NSData *existingKeyHash = [PearlKeyChain dataOfItemForQuery:keyHashQuery()];
|
||||||
[PearlKeyChain addOrUpdateItemForQuery:keyHashQuery()
|
if (![existingKeyHash isEqualToData:self.keyHash]) {
|
||||||
withAttributes:[NSDictionary dictionaryWithObjectsAndKeys:
|
inf(@"Updating key ID in keychain.");
|
||||||
self.keyHash, (__bridge id)kSecValueData,
|
[PearlKeyChain addOrUpdateItemForQuery:keyHashQuery()
|
||||||
#if TARGET_OS_IPHONE
|
|
||||||
kSecAttrAccessibleWhenUnlocked, (__bridge id)kSecAttrAccessible,
|
|
||||||
#endif
|
|
||||||
nil]];
|
|
||||||
if ([[MPConfig get].saveKey boolValue]) {
|
|
||||||
dbg(@"Storing key in key chain.");
|
|
||||||
[PearlKeyChain addOrUpdateItemForQuery:keyQuery()
|
|
||||||
withAttributes:[NSDictionary dictionaryWithObjectsAndKeys:
|
withAttributes:[NSDictionary dictionaryWithObjectsAndKeys:
|
||||||
self.key, (__bridge id)kSecValueData,
|
self.keyHash, (__bridge id)kSecValueData,
|
||||||
#if TARGET_OS_IPHONE
|
#if TARGET_OS_IPHONE
|
||||||
kSecAttrAccessibleWhenUnlocked, (__bridge id)kSecAttrAccessible,
|
kSecAttrAccessibleWhenUnlocked, (__bridge id)kSecAttrAccessible,
|
||||||
#endif
|
#endif
|
||||||
nil]];
|
nil]];
|
||||||
}
|
}
|
||||||
|
if ([[MPConfig get].saveKey boolValue]) {
|
||||||
|
NSData *existingKey = [PearlKeyChain dataOfItemForQuery:keyQuery()];
|
||||||
|
if (![existingKey isEqualToData:self.key]) {
|
||||||
|
inf(@"Updating key in keychain.");
|
||||||
|
[PearlKeyChain addOrUpdateItemForQuery:keyQuery()
|
||||||
|
withAttributes:[NSDictionary dictionaryWithObjectsAndKeys:
|
||||||
|
self.key, (__bridge id)kSecValueData,
|
||||||
|
#if TARGET_OS_IPHONE
|
||||||
|
kSecAttrAccessibleWhenUnlocked, (__bridge id)kSecAttrAccessible,
|
||||||
|
#endif
|
||||||
|
nil]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#ifdef TESTFLIGHT_SDK_VERSION
|
#ifdef TESTFLIGHT_SDK_VERSION
|
||||||
[TestFlight passCheckpoint:MPTestFlightCheckpointSetKey];
|
[TestFlight passCheckpoint:MPTestFlightCheckpointSetKey];
|
||||||
|
@ -120,7 +120,7 @@ static NSDateFormatter *rfc3339DateFormatter = nil;
|
|||||||
NSError *error = nil;
|
NSError *error = nil;
|
||||||
if ([self.managedObjectContext hasChanges])
|
if ([self.managedObjectContext hasChanges])
|
||||||
if (![self.managedObjectContext save:&error])
|
if (![self.managedObjectContext save:&error])
|
||||||
err(@"Unresolved error %@", error);
|
err(@"While saving context: %@", error);
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -178,7 +178,7 @@ static NSDateFormatter *rfc3339DateFormatter = nil;
|
|||||||
|
|
||||||
- (void)ubiquityStoreManager:(UbiquityStoreManager *)manager log:(NSString *)message {
|
- (void)ubiquityStoreManager:(UbiquityStoreManager *)manager log:(NSString *)message {
|
||||||
|
|
||||||
dbg(@"StoreManager: %@", message);
|
dbg(@"[StoreManager] %@", message);
|
||||||
}
|
}
|
||||||
|
|
||||||
- (void)ubiquityStoreManager:(UbiquityStoreManager *)manager didSwitchToiCloud:(BOOL)didSwitch {
|
- (void)ubiquityStoreManager:(UbiquityStoreManager *)manager didSwitchToiCloud:(BOOL)didSwitch {
|
||||||
@ -192,7 +192,7 @@ static NSDateFormatter *rfc3339DateFormatter = nil;
|
|||||||
}
|
}
|
||||||
|
|
||||||
- (void)ubiquityStoreManager:(UbiquityStoreManager *)manager didEncounterError:(NSError *)error cause:(UbiquityStoreManagerErrorCause)cause context:(id)context {
|
- (void)ubiquityStoreManager:(UbiquityStoreManager *)manager didEncounterError:(NSError *)error cause:(UbiquityStoreManagerErrorCause)cause context:(id)context {
|
||||||
|
|
||||||
#ifdef TESTFLIGHT_SDK_VERSION
|
#ifdef TESTFLIGHT_SDK_VERSION
|
||||||
[TestFlight passCheckpoint:str(@"MPTestFlightCheckpointMPErrorUbiquity_%d", cause)];
|
[TestFlight passCheckpoint:str(@"MPTestFlightCheckpointMPErrorUbiquity_%d", cause)];
|
||||||
#endif
|
#endif
|
||||||
@ -338,11 +338,11 @@ static NSDateFormatter *rfc3339DateFormatter = nil;
|
|||||||
|
|
||||||
// Delete existing sites.
|
// Delete existing sites.
|
||||||
[elementsToDelete enumerateObjectsUsingBlock:^(id obj, BOOL *stop) {
|
[elementsToDelete enumerateObjectsUsingBlock:^(id obj, BOOL *stop) {
|
||||||
dbg(@"Deleting: %@", [obj name]);
|
inf(@"Deleting site: %@, it will be replaced by an imported site.", [obj name]);
|
||||||
[self.managedObjectContext deleteObject:obj];
|
[self.managedObjectContext deleteObject:obj];
|
||||||
}];
|
}];
|
||||||
[self saveContext];
|
[self saveContext];
|
||||||
|
|
||||||
// Import new sites.
|
// Import new sites.
|
||||||
for (NSArray *siteElements in importedSiteElements) {
|
for (NSArray *siteElements in importedSiteElements) {
|
||||||
NSDate *lastUsed = [rfc3339DateFormatter dateFromString:[siteElements objectAtIndex:0]];
|
NSDate *lastUsed = [rfc3339DateFormatter dateFromString:[siteElements objectAtIndex:0]];
|
||||||
@ -352,7 +352,7 @@ static NSDateFormatter *rfc3339DateFormatter = nil;
|
|||||||
NSString *exportContent = [siteElements objectAtIndex:4];
|
NSString *exportContent = [siteElements objectAtIndex:4];
|
||||||
|
|
||||||
// Create new site.
|
// Create new site.
|
||||||
dbg(@"Creating: name=%@, lastUsed=%@, uses=%d, type=%u, keyID=%@", name, lastUsed, uses, type, keyID);
|
inf(@"Importing site: name=%@, lastUsed=%@, uses=%d, type=%u, keyID=%@", name, lastUsed, uses, type, keyID);
|
||||||
MPElementEntity *element = [NSEntityDescription insertNewObjectForEntityForName:ClassNameFromMPElementType(type)
|
MPElementEntity *element = [NSEntityDescription insertNewObjectForEntityForName:ClassNameFromMPElementType(type)
|
||||||
inManagedObjectContext:self.managedObjectContext];
|
inManagedObjectContext:self.managedObjectContext];
|
||||||
element.name = name;
|
element.name = name;
|
||||||
@ -364,11 +364,11 @@ static NSDateFormatter *rfc3339DateFormatter = nil;
|
|||||||
[element importContent:exportContent];
|
[element importContent:exportContent];
|
||||||
}
|
}
|
||||||
[self saveContext];
|
[self saveContext];
|
||||||
|
|
||||||
#ifdef TESTFLIGHT_SDK_VERSION
|
#ifdef TESTFLIGHT_SDK_VERSION
|
||||||
[TestFlight passCheckpoint:MPTestFlightCheckpointSitesImported];
|
[TestFlight passCheckpoint:MPTestFlightCheckpointSitesImported];
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
return MPImportResultSuccess;
|
return MPImportResultSuccess;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -428,7 +428,7 @@ static NSDateFormatter *rfc3339DateFormatter = nil;
|
|||||||
#ifdef TESTFLIGHT_SDK_VERSION
|
#ifdef TESTFLIGHT_SDK_VERSION
|
||||||
[TestFlight passCheckpoint:MPTestFlightCheckpointSitesExported];
|
[TestFlight passCheckpoint:MPTestFlightCheckpointSitesExported];
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
return export;
|
return export;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -13,6 +13,6 @@
|
|||||||
|
|
||||||
@interface MPElementGeneratedEntity : MPElementEntity
|
@interface MPElementGeneratedEntity : MPElementEntity
|
||||||
|
|
||||||
@property (nonatomic, assign) int16_t counter;
|
@property (nonatomic, assign) int32_t counter;
|
||||||
|
|
||||||
@end
|
@end
|
||||||
|
@ -18,7 +18,7 @@
|
|||||||
- (id)content {
|
- (id)content {
|
||||||
|
|
||||||
if (!(self.type & MPElementTypeClassGenerated)) {
|
if (!(self.type & MPElementTypeClassGenerated)) {
|
||||||
err(@"Corrupt element: %@, type: %d, does not match class: %@", self.name, self.type, [self class]);
|
err(@"Corrupt element: %@, type: %d is not in MPElementTypeClassGenerated", self.name, self.type);
|
||||||
return nil;
|
return nil;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -82,4 +82,4 @@ NSData *keyHashForKey(NSData *key);
|
|||||||
NSString *NSStringFromMPElementType(MPElementType type);
|
NSString *NSStringFromMPElementType(MPElementType type);
|
||||||
NSString *ClassNameFromMPElementType(MPElementType type);
|
NSString *ClassNameFromMPElementType(MPElementType type);
|
||||||
Class ClassFromMPElementType(MPElementType type);
|
Class ClassFromMPElementType(MPElementType type);
|
||||||
NSString *MPCalculateContent(MPElementType type, NSString *name, NSData *key, int16_t counter);
|
NSString *MPCalculateContent(MPElementType type, NSString *name, NSData *key, int32_t counter);
|
||||||
|
@ -9,6 +9,8 @@
|
|||||||
#import "MPTypes.h"
|
#import "MPTypes.h"
|
||||||
#import "MPElementGeneratedEntity.h"
|
#import "MPElementGeneratedEntity.h"
|
||||||
#import "MPElementStoredEntity.h"
|
#import "MPElementStoredEntity.h"
|
||||||
|
#include <endian.h>
|
||||||
|
|
||||||
|
|
||||||
#define MP_salt nil
|
#define MP_salt nil
|
||||||
#define MP_N 16384
|
#define MP_N 16384
|
||||||
@ -21,7 +23,8 @@ NSData *keyForPassword(NSString *password) {
|
|||||||
|
|
||||||
NSData *key = [PearlSCrypt deriveKeyWithLength:MP_dkLen fromPassword:[password dataUsingEncoding:NSUTF8StringEncoding]
|
NSData *key = [PearlSCrypt deriveKeyWithLength:MP_dkLen fromPassword:[password dataUsingEncoding:NSUTF8StringEncoding]
|
||||||
usingSalt:MP_salt N:MP_N r:MP_r p:MP_p];
|
usingSalt:MP_salt N:MP_N r:MP_r p:MP_p];
|
||||||
trc(@"password: %@ derives to key: %@", password, key);
|
|
||||||
|
trc(@"Password: %@ derives to key ID: %@", password, [keyHashForKey(key) encodeHex]);
|
||||||
return key;
|
return key;
|
||||||
}
|
}
|
||||||
NSData *keyHashForPassword(NSString *password) {
|
NSData *keyHashForPassword(NSString *password) {
|
||||||
@ -102,32 +105,37 @@ NSString *ClassNameFromMPElementType(MPElementType type) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static NSDictionary *MPTypes_ciphers = nil;
|
static NSDictionary *MPTypes_ciphers = nil;
|
||||||
NSString *MPCalculateContent(MPElementType type, NSString *name, NSData *key, int16_t counter) {
|
NSString *MPCalculateContent(MPElementType type, NSString *name, NSData *key, int32_t counter) {
|
||||||
|
|
||||||
if (!name) {
|
|
||||||
err(@"Missing name.");
|
|
||||||
return nil;
|
|
||||||
}
|
|
||||||
if (!(type & MPElementTypeClassGenerated)) {
|
if (!(type & MPElementTypeClassGenerated)) {
|
||||||
err(@"Incorrect type (is not MPElementTypeClassGenerated): %d, for: %@", type, name);
|
err(@"Incorrect type (is not MPElementTypeClassGenerated): %d, for: %@", type, name);
|
||||||
return nil;
|
return nil;
|
||||||
}
|
}
|
||||||
|
if (!name) {
|
||||||
|
err(@"Missing name.");
|
||||||
|
return nil;
|
||||||
|
}
|
||||||
if (!key) {
|
if (!key) {
|
||||||
err(@"Key not set.");
|
err(@"Key not set.");
|
||||||
return nil;
|
return nil;
|
||||||
}
|
}
|
||||||
|
uint32_t salt = (unsigned)counter;
|
||||||
|
if (!counter)
|
||||||
|
// Counter unset, go into OTP mode.
|
||||||
|
// Get the UNIX timestamp of the start of the interval of 5 minutes that the current time is in.
|
||||||
|
salt = ((uint32_t)([[NSDate date] timeIntervalSince1970] / 300)) * 300;
|
||||||
|
|
||||||
if (MPTypes_ciphers == nil)
|
if (MPTypes_ciphers == nil)
|
||||||
MPTypes_ciphers = [NSDictionary dictionaryWithContentsOfURL:[[NSBundle mainBundle] URLForResource:@"ciphers"
|
MPTypes_ciphers = [NSDictionary dictionaryWithContentsOfURL:[[NSBundle mainBundle] URLForResource:@"ciphers"
|
||||||
withExtension:@"plist"]];
|
withExtension:@"plist"]];
|
||||||
|
|
||||||
// Determine the hash whose bytes will be used for calculating a password: md4(name-key)
|
// Determine the hash whose bytes will be used for calculating a password: md4(name-key)
|
||||||
uint16_t ncounter = htons(counter);
|
uint32_t nsalt = htonl(salt);
|
||||||
trc(@"key hash from: %@-%@-%u", name, key, ncounter);
|
trc(@"key hash from: %@-%@-%u", name, key, nsalt);
|
||||||
NSData *keyHash = [[NSData dataByConcatenatingWithDelimitor:'-' datas:
|
NSData *keyHash = [[NSData dataByConcatenatingWithDelimitor:'-' datas:
|
||||||
[name dataUsingEncoding:NSUTF8StringEncoding],
|
[name dataUsingEncoding:NSUTF8StringEncoding],
|
||||||
key,
|
key,
|
||||||
[NSData dataWithBytes:&ncounter length:sizeof(ncounter)],
|
[NSData dataWithBytes:&nsalt length:sizeof(nsalt)],
|
||||||
nil] hashWith:PearlDigestSHA1];
|
nil] hashWith:PearlDigestSHA1];
|
||||||
trc(@"key hash is: %@", keyHash);
|
trc(@"key hash is: %@", keyHash);
|
||||||
const char *keyBytes = keyHash.bytes;
|
const char *keyBytes = keyHash.bytes;
|
||||||
|
@ -8,7 +8,7 @@
|
|||||||
<attribute name="uses" attributeType="Integer 16" defaultValueString="0" syncable="YES"/>
|
<attribute name="uses" attributeType="Integer 16" defaultValueString="0" syncable="YES"/>
|
||||||
</entity>
|
</entity>
|
||||||
<entity name="MPElementGeneratedEntity" representedClassName="MPElementGeneratedEntity" parentEntity="MPElementEntity" syncable="YES">
|
<entity name="MPElementGeneratedEntity" representedClassName="MPElementGeneratedEntity" parentEntity="MPElementEntity" syncable="YES">
|
||||||
<attribute name="counter" optional="YES" attributeType="Integer 16" defaultValueString="1" syncable="YES"/>
|
<attribute name="counter" optional="YES" attributeType="Integer 32" defaultValueString="1" syncable="YES"/>
|
||||||
</entity>
|
</entity>
|
||||||
<entity name="MPElementStoredEntity" representedClassName="MPElementStoredEntity" parentEntity="MPElementEntity" syncable="YES">
|
<entity name="MPElementStoredEntity" representedClassName="MPElementStoredEntity" parentEntity="MPElementEntity" syncable="YES">
|
||||||
<attribute name="contentObject" optional="YES" attributeType="Transformable" storedInTruthFile="YES" syncable="YES"/>
|
<attribute name="contentObject" optional="YES" attributeType="Transformable" storedInTruthFile="YES" syncable="YES"/>
|
||||||
|
@ -98,8 +98,8 @@ typedef enum {
|
|||||||
|
|
||||||
[[NSNotificationCenter defaultCenter] addObserverForName:MPNotificationKeyForgotten
|
[[NSNotificationCenter defaultCenter] addObserverForName:MPNotificationKeyForgotten
|
||||||
object:nil queue:nil usingBlock:^(NSNotification *note) {
|
object:nil queue:nil usingBlock:^(NSNotification *note) {
|
||||||
[self.field becomeFirstResponder];
|
[self.field becomeFirstResponder];
|
||||||
}];
|
}];
|
||||||
|
|
||||||
[super viewDidLoad];
|
[super viewDidLoad];
|
||||||
}
|
}
|
||||||
@ -200,7 +200,6 @@ typedef enum {
|
|||||||
|
|
||||||
- (IBAction)changeMP {
|
- (IBAction)changeMP {
|
||||||
|
|
||||||
dbg(@"Forgetting key phrase.");
|
|
||||||
[PearlAlert showAlertWithTitle:@"Changing Master Password"
|
[PearlAlert showAlertWithTitle:@"Changing Master Password"
|
||||||
message:
|
message:
|
||||||
@"This will allow you to log in with a different master password.\n\n"
|
@"This will allow you to log in with a different master password.\n\n"
|
||||||
@ -210,9 +209,10 @@ typedef enum {
|
|||||||
@"Your current sites and passwords will then become available again."
|
@"Your current sites and passwords will then become available again."
|
||||||
viewStyle:UIAlertViewStyleDefault
|
viewStyle:UIAlertViewStyleDefault
|
||||||
tappedButtonBlock:^(UIAlertView *alert, NSInteger buttonIndex) {
|
tappedButtonBlock:^(UIAlertView *alert, NSInteger buttonIndex) {
|
||||||
if (buttonIndex != [alert cancelButtonIndex])
|
if (buttonIndex == [alert cancelButtonIndex])
|
||||||
[[MPAppDelegate get] forgetKey];
|
return;
|
||||||
|
|
||||||
|
[[MPAppDelegate get] forgetKey];
|
||||||
[[MPAppDelegate get] loadKey:YES];
|
[[MPAppDelegate get] loadKey:YES];
|
||||||
|
|
||||||
[TestFlight passCheckpoint:MPTestFlightCheckpointMPChanged];
|
[TestFlight passCheckpoint:MPTestFlightCheckpointMPChanged];
|
||||||
|
Loading…
Reference in New Issue
Block a user