Migration of saved passwords.
[ADDED] Migrate saved passwords when master password changes. [IMPROVED] UI improvements to apps.
2
External/Pearl
vendored
@ -1 +1 @@
|
|||||||
Subproject commit ff1e29fc3a4c2ee7b08eb686d06f29a11466bbe5
|
Subproject commit 552cf5ffc9445362f8f91357f4f7a034b2c390a3
|
@ -28,7 +28,9 @@ static NSDictionary *keyQuery(MPUserEntity *user) {
|
|||||||
inf(@"Found key in keychain for: %@", user.userID);
|
inf(@"Found key in keychain for: %@", user.userID);
|
||||||
|
|
||||||
else {
|
else {
|
||||||
user.saveKey = NO;
|
[user.managedObjectContext performBlockAndWait:^{
|
||||||
|
user.saveKey = NO;
|
||||||
|
}];
|
||||||
inf(@"No key found in keychain for: %@", user.userID);
|
inf(@"No key found in keychain for: %@", user.userID);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -58,7 +60,9 @@ static NSDictionary *keyQuery(MPUserEntity *user) {
|
|||||||
|
|
||||||
OSStatus result = [PearlKeyChain deleteItemForQuery:keyQuery(user)];
|
OSStatus result = [PearlKeyChain deleteItemForQuery:keyQuery(user)];
|
||||||
if (result == noErr || result == errSecItemNotFound) {
|
if (result == noErr || result == errSecItemNotFound) {
|
||||||
user.saveKey = NO;
|
[user.managedObjectContext performBlockAndWait:^{
|
||||||
|
user.saveKey = NO;
|
||||||
|
}];
|
||||||
|
|
||||||
if (result == noErr) {
|
if (result == noErr) {
|
||||||
inf(@"Removed key from keychain for: %@", user.userID);
|
inf(@"Removed key from keychain for: %@", user.userID);
|
||||||
@ -82,14 +86,68 @@ static NSDictionary *keyQuery(MPUserEntity *user) {
|
|||||||
|
|
||||||
- (BOOL)signInAsUser:(MPUserEntity *)user usingMasterPassword:(NSString *)password {
|
- (BOOL)signInAsUser:(MPUserEntity *)user usingMasterPassword:(NSString *)password {
|
||||||
|
|
||||||
|
assert(!password || ![NSThread isMainThread]); // If we need to computing a key, this operation shouldn't be on the main thread.
|
||||||
MPKey *tryKey = nil;
|
MPKey *tryKey = nil;
|
||||||
|
|
||||||
// Method 1: When the user has no keyID set, set a new key from the given master password.
|
// Method 1: When the user has no keyID set, set a new key from the given master password.
|
||||||
if (!user.keyID) {
|
if (!user.keyID) {
|
||||||
if ([password length])
|
if ([password length])
|
||||||
if ((tryKey = [MPAlgorithmDefault keyForPassword:password ofUserNamed:user.name])) {
|
if ((tryKey = [MPAlgorithmDefault keyForPassword:password ofUserNamed:user.name])) {
|
||||||
user.keyID = tryKey.keyID;
|
[user.managedObjectContext performBlockAndWait:^{
|
||||||
|
user.keyID = tryKey.keyID;
|
||||||
|
}];
|
||||||
|
|
||||||
|
// Migrate existing elements.
|
||||||
|
MPKey *recoverKey = nil;
|
||||||
|
PearlAlert *activityAlert = [PearlAlert showActivityWithTitle:PearlString(@"Migrating %d sites...", [user.elements count])];
|
||||||
|
|
||||||
|
for (MPElementEntity *element in user.elements) {
|
||||||
|
if (element.type & MPElementTypeClassStored && ![element contentUsingKey:tryKey]) {
|
||||||
|
id content = nil;
|
||||||
|
if (recoverKey)
|
||||||
|
content = [element contentUsingKey:recoverKey];
|
||||||
|
|
||||||
|
while (!content) {
|
||||||
|
__block NSString *masterPassword = nil;
|
||||||
|
dispatch_group_t recoverPasswordGroup = dispatch_group_create();
|
||||||
|
dispatch_group_enter(recoverPasswordGroup);
|
||||||
|
[PearlAlert showAlertWithTitle:@"Enter Old Master Password"
|
||||||
|
message:PearlString(@"Your old master password is required to migrate the stored password for %@", element.name)
|
||||||
|
viewStyle:UIAlertViewStyleSecureTextInput
|
||||||
|
initAlert:nil
|
||||||
|
tappedButtonBlock:^(UIAlertView *alert_, NSInteger buttonIndex_) {
|
||||||
|
@try {
|
||||||
|
if (buttonIndex_ == [alert_ cancelButtonIndex])
|
||||||
|
// Don't Migrate
|
||||||
|
return;
|
||||||
|
|
||||||
|
masterPassword = [alert_ textFieldAtIndex:0].text;
|
||||||
|
}
|
||||||
|
@finally {
|
||||||
|
dispatch_group_leave(recoverPasswordGroup);
|
||||||
|
}
|
||||||
|
} cancelTitle:@"Don't Migrate" otherTitles:@"Migrate", nil];
|
||||||
|
dispatch_group_wait(recoverPasswordGroup, DISPATCH_TIME_FOREVER);
|
||||||
|
|
||||||
|
if (!masterPassword)
|
||||||
|
// Don't Migrate
|
||||||
|
break;
|
||||||
|
|
||||||
|
recoverKey = [element.algorithm keyForPassword:masterPassword ofUserNamed:user.name];
|
||||||
|
content = [element contentUsingKey:recoverKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!content)
|
||||||
|
// Don't Migrate
|
||||||
|
break;
|
||||||
|
|
||||||
|
[element.managedObjectContext performBlockAndWait:^{
|
||||||
|
[element setContent:content usingKey:tryKey];
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
}
|
||||||
[[MPAppDelegate_Shared get] saveContext];
|
[[MPAppDelegate_Shared get] saveContext];
|
||||||
|
[activityAlert dismissAlert];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -152,9 +210,11 @@ static NSDictionary *keyQuery(MPUserEntity *user) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
user.lastUsed = [NSDate date];
|
[user.managedObjectContext performBlockAndWait:^{
|
||||||
self.activeUser = user;
|
user.lastUsed = [NSDate date];
|
||||||
self.activeUser.requiresExplicitMigration = NO;
|
self.activeUser = user;
|
||||||
|
self.activeUser.requiresExplicitMigration = NO;
|
||||||
|
}];
|
||||||
[[MPAppDelegate_Shared get] saveContext];
|
[[MPAppDelegate_Shared get] saveContext];
|
||||||
|
|
||||||
[[NSNotificationCenter defaultCenter] postNotificationName:MPNotificationSignedIn object:self];
|
[[NSNotificationCenter defaultCenter] postNotificationName:MPNotificationSignedIn object:self];
|
||||||
|
@ -27,6 +27,9 @@
|
|||||||
@property (assign) BOOL requiresExplicitMigration;
|
@property (assign) BOOL requiresExplicitMigration;
|
||||||
@property (readonly) id<MPAlgorithm> algorithm;
|
@property (readonly) id<MPAlgorithm> algorithm;
|
||||||
|
|
||||||
|
- (id)contentUsingKey:(MPKey *)key;
|
||||||
|
- (void)setContent:(id)content usingKey:(MPKey *)key;
|
||||||
|
|
||||||
- (NSUInteger)use;
|
- (NSUInteger)use;
|
||||||
- (NSString *)exportContent;
|
- (NSString *)exportContent;
|
||||||
- (void)importProtectedContent:(NSString *)protectedContent protectedByKey:(MPKey *)contentProtectionKey usingKey:(MPKey *)key2;
|
- (void)importProtectedContent:(NSString *)protectedContent protectedByKey:(MPKey *)contentProtectionKey usingKey:(MPKey *)key2;
|
||||||
|
@ -84,7 +84,32 @@
|
|||||||
|
|
||||||
- (id)content {
|
- (id)content {
|
||||||
|
|
||||||
@throw [NSException exceptionWithName:NSInternalInconsistencyException reason:@"Content implementation missing." userInfo:nil];
|
MPKey *key = [MPAppDelegate get].key;
|
||||||
|
if (!key)
|
||||||
|
return nil;
|
||||||
|
|
||||||
|
assert([key.keyID isEqualToData:self.user.keyID]);
|
||||||
|
return [self contentUsingKey:key];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)setContent:(id)content {
|
||||||
|
|
||||||
|
MPKey *key = [MPAppDelegate get].key;
|
||||||
|
if (!key)
|
||||||
|
return;
|
||||||
|
|
||||||
|
assert([key.keyID isEqualToData:self.user.keyID]);
|
||||||
|
[self setContent:content usingKey:key];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (id)contentUsingKey:(MPKey *)key {
|
||||||
|
|
||||||
|
Throw(@"Content retrieval implementation missing for: %@", [self class]);
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)setContent:(id)content usingKey:(MPKey *)key {
|
||||||
|
|
||||||
|
Throw(@"Content assignment implementation missing for: %@", [self class]);
|
||||||
}
|
}
|
||||||
|
|
||||||
- (NSString *)exportContent {
|
- (NSString *)exportContent {
|
||||||
@ -139,11 +164,7 @@
|
|||||||
self.counter_ = @(aCounter);
|
self.counter_ = @(aCounter);
|
||||||
}
|
}
|
||||||
|
|
||||||
- (id)content {
|
- (id)contentUsingKey:(MPKey *)key {
|
||||||
|
|
||||||
MPKey *key = [MPAppDelegate get].key;
|
|
||||||
if (!key)
|
|
||||||
return nil;
|
|
||||||
|
|
||||||
if (!(self.type & MPElementTypeClassGenerated)) {
|
if (!(self.type & MPElementTypeClassGenerated)) {
|
||||||
err(@"Corrupt element: %@, type: %d is not in MPElementTypeClassGenerated", self.name, self.type);
|
err(@"Corrupt element: %@, type: %d is not in MPElementTypeClassGenerated", self.name, self.type);
|
||||||
@ -156,6 +177,7 @@
|
|||||||
return [self.algorithm generateContentForElement:self usingKey:key];
|
return [self.algorithm generateContentForElement:self usingKey:key];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@end
|
@end
|
||||||
|
|
||||||
@implementation MPElementStoredEntity (MP)
|
@implementation MPElementStoredEntity (MP)
|
||||||
@ -168,28 +190,9 @@
|
|||||||
matches:nil];
|
matches:nil];
|
||||||
}
|
}
|
||||||
|
|
||||||
- (id)content {
|
|
||||||
|
|
||||||
MPKey *key = [MPAppDelegate get].key;
|
|
||||||
if (!key)
|
|
||||||
return nil;
|
|
||||||
|
|
||||||
return [self contentUsingKey:key];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)setContent:(id)content {
|
|
||||||
|
|
||||||
MPKey *key = [MPAppDelegate get].key;
|
|
||||||
if (!key)
|
|
||||||
return;
|
|
||||||
|
|
||||||
[self setContent:content usingKey:key];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (id)contentUsingKey:(MPKey *)key {
|
- (id)contentUsingKey:(MPKey *)key {
|
||||||
|
|
||||||
assert(self.type & MPElementTypeClassStored);
|
assert(self.type & MPElementTypeClassStored);
|
||||||
assert([key.keyID isEqualToData:self.user.keyID]);
|
|
||||||
|
|
||||||
NSData *encryptedContent;
|
NSData *encryptedContent;
|
||||||
if (self.type & MPElementFeatureDevicePrivate)
|
if (self.type & MPElementFeatureDevicePrivate)
|
||||||
@ -201,6 +204,9 @@
|
|||||||
if ([encryptedContent length])
|
if ([encryptedContent length])
|
||||||
decryptedContent = [self decryptContent:encryptedContent usingKey:key];
|
decryptedContent = [self decryptContent:encryptedContent usingKey:key];
|
||||||
|
|
||||||
|
if (!decryptedContent)
|
||||||
|
return nil;
|
||||||
|
|
||||||
return [[NSString alloc] initWithBytes:decryptedContent.bytes length:decryptedContent.length encoding:NSUTF8StringEncoding];
|
return [[NSString alloc] initWithBytes:decryptedContent.bytes length:decryptedContent.length encoding:NSUTF8StringEncoding];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -241,15 +241,7 @@
|
|||||||
if (!importedSitesData)
|
if (!importedSitesData)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
PearlAlert *activityAlert = [PearlAlert showAlertWithTitle:@"Importing" message:@"\n\n"
|
PearlAlert *activityAlert = [PearlAlert showActivityWithTitle:@"Importing"];
|
||||||
viewStyle:UIAlertViewStyleDefault initAlert:
|
|
||||||
^(UIAlertView *alert, UITextField *firstField) {
|
|
||||||
UIActivityIndicatorView *activityIndicator = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleWhiteLarge];
|
|
||||||
activityIndicator.center = CGPointMake(140, 90);
|
|
||||||
[activityIndicator startAnimating];
|
|
||||||
[alert addSubview:activityIndicator];
|
|
||||||
}
|
|
||||||
tappedButtonBlock:nil cancelTitle:nil otherTitles:nil];
|
|
||||||
|
|
||||||
NSString *importedSitesString = [[NSString alloc] initWithData:importedSitesData encoding:NSUTF8StringEncoding];
|
NSString *importedSitesString = [[NSString alloc] initWithData:importedSitesData encoding:NSUTF8StringEncoding];
|
||||||
MPImportResult result = [self importSites:importedSitesString askImportPassword:^NSString *(NSString *userName) {
|
MPImportResult result = [self importSites:importedSitesString askImportPassword:^NSString *(NSString *userName) {
|
||||||
|
@ -96,9 +96,7 @@
|
|||||||
viewControllerAfterViewController:(UIViewController *)viewController {
|
viewControllerAfterViewController:(UIViewController *)viewController {
|
||||||
|
|
||||||
NSUInteger vcIndex = [self.pageVCs indexOfObject:viewController];
|
NSUInteger vcIndex = [self.pageVCs indexOfObject:viewController];
|
||||||
UIPageViewController *vc = [self.pageVCs objectAtIndex:(vcIndex + 1) % self.pageVCs.count];
|
return [self.pageVCs objectAtIndex:(vcIndex + 1) % self.pageVCs.count];
|
||||||
|
|
||||||
return vc;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
- (void)viewDidUnload {
|
- (void)viewDidUnload {
|
||||||
|
@ -1167,11 +1167,11 @@ Pink fluffy door frame.</string>
|
|||||||
<placeholder placeholderIdentifier="IBFirstResponder" id="hkm-U7-Dm7" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
<placeholder placeholderIdentifier="IBFirstResponder" id="hkm-U7-Dm7" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||||
<viewController storyboardIdentifier="MPAppViewController_0" id="yIx-9U-bOF" customClass="MPAppViewController" sceneMemberID="viewController">
|
<viewController storyboardIdentifier="MPAppViewController_0" id="yIx-9U-bOF" customClass="MPAppViewController" sceneMemberID="viewController">
|
||||||
<view key="view" contentMode="scaleToFill" id="Yea-s2-C8N">
|
<view key="view" contentMode="scaleToFill" id="Yea-s2-C8N">
|
||||||
<rect key="frame" x="0.0" y="0.0" width="305" height="399"/>
|
<rect key="frame" x="0.0" y="0.0" width="305" height="400"/>
|
||||||
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||||
<subviews>
|
<subviews>
|
||||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" id="Wrz-tq-o1S">
|
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" id="Wrz-tq-o1S">
|
||||||
<rect key="frame" x="0.0" y="0.0" width="305" height="399"/>
|
<rect key="frame" x="0.0" y="0.0" width="305" height="400"/>
|
||||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||||
<fontDescription key="fontDescription" type="boldSystem" pointSize="15"/>
|
<fontDescription key="fontDescription" type="boldSystem" pointSize="15"/>
|
||||||
<state key="normal" image="page-gorillas.png">
|
<state key="normal" image="page-gorillas.png">
|
||||||
@ -1186,7 +1186,7 @@ Pink fluffy door frame.</string>
|
|||||||
</connections>
|
</connections>
|
||||||
</button>
|
</button>
|
||||||
<view contentMode="scaleToFill" id="JN1-cA-6yZ">
|
<view contentMode="scaleToFill" id="JN1-cA-6yZ">
|
||||||
<rect key="frame" x="0.0" y="330" width="305" height="60"/>
|
<rect key="frame" x="0.0" y="331" width="305" height="60"/>
|
||||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" flexibleMinY="YES"/>
|
<autoresizingMask key="autoresizingMask" widthSizable="YES" flexibleMinY="YES"/>
|
||||||
<subviews>
|
<subviews>
|
||||||
<label opaque="NO" clipsSubviews="YES" userInteractionEnabled="NO" contentMode="left" text="Gorillas At Large On Metropolis Rooftops" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumFontSize="10" id="Ypg-Yc-UK3">
|
<label opaque="NO" clipsSubviews="YES" userInteractionEnabled="NO" contentMode="left" text="Gorillas At Large On Metropolis Rooftops" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumFontSize="10" id="Ypg-Yc-UK3">
|
||||||
@ -1632,7 +1632,7 @@ You could use the word wall for inspiration in finding a memorable master passw
|
|||||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||||
</imageView>
|
</imageView>
|
||||||
<imageView userInteractionEnabled="NO" contentMode="scaleToFill" image="page-gorillas.png" id="QQT-37-azo">
|
<imageView userInteractionEnabled="NO" contentMode="scaleToFill" image="page-gorillas.png" id="QQT-37-azo">
|
||||||
<rect key="frame" x="0.0" y="38" width="305" height="399"/>
|
<rect key="frame" x="0.0" y="38" width="305" height="400"/>
|
||||||
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMinY="YES" flexibleMaxY="YES"/>
|
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMinY="YES" flexibleMaxY="YES"/>
|
||||||
</imageView>
|
</imageView>
|
||||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" id="drq-47-KK9">
|
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" id="drq-47-KK9">
|
||||||
@ -2021,11 +2021,11 @@ You could use the word wall for inspiration in finding a memorable master passw
|
|||||||
<placeholder placeholderIdentifier="IBFirstResponder" id="LHv-Mk-8Kp" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
<placeholder placeholderIdentifier="IBFirstResponder" id="LHv-Mk-8Kp" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||||
<viewController storyboardIdentifier="MPAppViewController_1" id="vOg-Xq-hKm" customClass="MPAppViewController" sceneMemberID="viewController">
|
<viewController storyboardIdentifier="MPAppViewController_1" id="vOg-Xq-hKm" customClass="MPAppViewController" sceneMemberID="viewController">
|
||||||
<view key="view" contentMode="scaleToFill" id="jzQ-Oa-Gdj">
|
<view key="view" contentMode="scaleToFill" id="jzQ-Oa-Gdj">
|
||||||
<rect key="frame" x="0.0" y="0.0" width="305" height="399"/>
|
<rect key="frame" x="0.0" y="0.0" width="305" height="400"/>
|
||||||
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||||
<subviews>
|
<subviews>
|
||||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" id="x6i-3e-0Rb">
|
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" id="x6i-3e-0Rb">
|
||||||
<rect key="frame" x="0.0" y="0.0" width="305" height="399"/>
|
<rect key="frame" x="0.0" y="0.0" width="305" height="400"/>
|
||||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||||
<fontDescription key="fontDescription" type="boldSystem" pointSize="15"/>
|
<fontDescription key="fontDescription" type="boldSystem" pointSize="15"/>
|
||||||
<state key="normal" image="page-deblock.png">
|
<state key="normal" image="page-deblock.png">
|
||||||
@ -2040,7 +2040,7 @@ You could use the word wall for inspiration in finding a memorable master passw
|
|||||||
</connections>
|
</connections>
|
||||||
</button>
|
</button>
|
||||||
<view contentMode="scaleToFill" id="AC5-4y-ftd">
|
<view contentMode="scaleToFill" id="AC5-4y-ftd">
|
||||||
<rect key="frame" x="0.0" y="330" width="305" height="60"/>
|
<rect key="frame" x="0.0" y="331" width="305" height="60"/>
|
||||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" flexibleMinY="YES"/>
|
<autoresizingMask key="autoresizingMask" widthSizable="YES" flexibleMinY="YES"/>
|
||||||
<subviews>
|
<subviews>
|
||||||
<label opaque="NO" clipsSubviews="YES" userInteractionEnabled="NO" contentMode="left" text="Block Destruction Puzzle" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumFontSize="10" id="11H-1K-20G">
|
<label opaque="NO" clipsSubviews="YES" userInteractionEnabled="NO" contentMode="left" text="Block Destruction Puzzle" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumFontSize="10" id="11H-1K-20G">
|
||||||
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 122 KiB |
Before Width: | Height: | Size: 410 KiB After Width: | Height: | Size: 411 KiB |
Before Width: | Height: | Size: 162 KiB After Width: | Height: | Size: 162 KiB |
Before Width: | Height: | Size: 597 KiB After Width: | Height: | Size: 598 KiB |