From f93f2a973aa130e616ab196d3b7afb464604d30b Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Thu, 3 Aug 2023 08:34:14 +0200 Subject: [PATCH] MOBILE-3839 ios: Implement secure storage plugin for iOS --- cordova-plugin-moodleapp/plugin.xml | 10 + .../src/ios/SecureStorage.h | 11 + .../src/ios/SecureStorage.m | 218 ++++++++++++++++++ cordova-plugin-moodleapp/src/ts/index.ts | 2 + .../src/ts/plugins/SecureStorage.ts | 85 +++++++ cordova-plugin-moodleapp/types/index.d.ts | 2 + 6 files changed, 328 insertions(+) create mode 100644 cordova-plugin-moodleapp/src/ios/SecureStorage.h create mode 100644 cordova-plugin-moodleapp/src/ios/SecureStorage.m create mode 100644 cordova-plugin-moodleapp/src/ts/plugins/SecureStorage.ts diff --git a/cordova-plugin-moodleapp/plugin.xml b/cordova-plugin-moodleapp/plugin.xml index a6abaa381..e161454be 100644 --- a/cordova-plugin-moodleapp/plugin.xml +++ b/cordova-plugin-moodleapp/plugin.xml @@ -13,4 +13,14 @@ + + + + + + + + + + diff --git a/cordova-plugin-moodleapp/src/ios/SecureStorage.h b/cordova-plugin-moodleapp/src/ios/SecureStorage.h new file mode 100644 index 000000000..71f1a8c6b --- /dev/null +++ b/cordova-plugin-moodleapp/src/ios/SecureStorage.h @@ -0,0 +1,11 @@ +#import +#import + +@interface SecureStorage : CDVPlugin {} + +- (void)get:(CDVInvokedUrlCommand*)command; +- (void)store:(CDVInvokedUrlCommand*)command; +- (void)delete:(CDVInvokedUrlCommand*)command; +- (void)deleteCollection:(CDVInvokedUrlCommand*)command; + +@end diff --git a/cordova-plugin-moodleapp/src/ios/SecureStorage.m b/cordova-plugin-moodleapp/src/ios/SecureStorage.m new file mode 100644 index 000000000..c71e752ff --- /dev/null +++ b/cordova-plugin-moodleapp/src/ios/SecureStorage.m @@ -0,0 +1,218 @@ +#import +#import +#import +#import "SecureStorage.h" + +@implementation SecureStorage + +- (void)get:(CDVInvokedUrlCommand*)command { + NSArray* names = [command argumentAtIndex:0]; + NSString* collection = [command argumentAtIndex:1 withDefault:@""]; + NSMutableDictionary* result = [NSMutableDictionary new]; + + NSLog(@"SecureStorage: Get values with names %@ in collection %@", names, collection); + + for (NSString* name in names) { + NSString* value = [self getValue:name inCollection:collection]; + if (value != nil) { + result[name] = value; + } + } + + CDVPluginResult* pluginResult = [CDVPluginResult resultWithStatus:CDVCommandStatus_OK messageAsDictionary:result]; + [self.commandDelegate sendPluginResult:pluginResult callbackId:command.callbackId]; +} + +- (NSString *)getValue:(NSString*)name inCollection:(NSString*)collection { + if ([name length] == 0) { + return nil; + } + + NSDictionary* query = @{ + (id)kSecClass: (id)kSecClassGenericPassword, + (id)kSecAttrAccount: name, + (id)kSecAttrService: collection, + (id)kSecReturnData: @YES, + (id)kSecMatchLimit: (id)kSecMatchLimitOne + }; + NSData* storedData = NULL; + + OSStatus status = SecItemCopyMatching((CFDictionaryRef)query, (void *) &storedData); + + if (status == errSecSuccess) { + return [[NSString alloc] initWithData:storedData encoding:NSUTF8StringEncoding]; + } else if (status != errSecItemNotFound) { + NSLog(@"Error getting value for %@ in collection %@. Status: %d", name, collection, (int) status); + } + + return nil; +} + +- (void)store:(CDVInvokedUrlCommand*)command { + + NSDictionary* data = [command argumentAtIndex:0]; + NSString* collection = [command argumentAtIndex:1 withDefault:@""]; + NSArray* names = [data allKeys]; + BOOL error = false; + + // Variables to be able to rollback changes if something fails. + NSMutableArray* insertedNames = [NSMutableArray new]; + NSMutableDictionary* previousValues = [NSMutableDictionary new]; + + NSLog(@"SecureStorage: Store values with names %@ in collection %@", names, collection); + + for (NSString* name in data) { + OSStatus status; + NSString* storedValue = [self getValue:name inCollection:collection]; + + if (storedValue != nil) { + status = [self updateName:name withValue:data[name] inCollection: collection]; + } else { + status = [self addName:name withValue:data[name] inCollection: collection]; + } + + if (status != errSecSuccess) { + NSLog(@"Error storing value for %@ in collection %@. Status: %d", name, collection, (int) status); + error = true; + + // Rollback. + for (NSString *name in insertedNames) { + [self deleteName:name fromCollection:collection]; + } + for(NSString *name in previousValues) { + [self updateName:name withValue:previousValues[name] inCollection: collection]; + } + + break; + } else if (storedValue != nil) { + previousValues[name] = storedValue; + } else { + [insertedNames addObject:name]; + } + } + + CDVPluginResult* pluginResult; + if (error) { + pluginResult = [CDVPluginResult resultWithStatus:CDVCommandStatus_ERROR + messageAsString:@"Error storing one or more values in secure storage."]; + } else { + pluginResult = [CDVPluginResult resultWithStatus:CDVCommandStatus_OK]; + } + + [self.commandDelegate sendPluginResult:pluginResult callbackId:command.callbackId]; +} + +- (OSStatus)addName:(NSString*)name withValue:(NSString*)value inCollection:(NSString*)collection { + NSData* newValue = [value dataUsingEncoding:NSUTF8StringEncoding]; + NSMutableDictionary* query = [NSMutableDictionary new]; + query[(id)kSecClass] = (id)kSecClassGenericPassword; + query[(id)kSecAttrAccount] = name; + query[(id)kSecAttrService] = collection; + query[(id)kSecAttrAccessible] = (id)kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly; + query[(id)kSecValueData] = newValue; + + return SecItemAdd((CFDictionaryRef)query, nil); +} + +- (OSStatus)updateName:(NSString*)name withValue:(NSString*)value inCollection:(NSString*)collection { + NSData* newValue = [value dataUsingEncoding:NSUTF8StringEncoding]; + NSMutableDictionary* query = [NSMutableDictionary new]; + query[(id)kSecClass] = (id)kSecClassGenericPassword; + query[(id)kSecAttrAccount] = name; + query[(id)kSecAttrService] = collection; + query[(id)kSecAttrAccessible] = (id)kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly; + + return SecItemUpdate((CFDictionaryRef)query, (CFDictionaryRef)@{ + (id)kSecValueData: newValue + }); +} + +- (void)delete:(CDVInvokedUrlCommand*)command { + NSArray* names = [command argumentAtIndex:0]; + NSString* collection = [command argumentAtIndex:1 withDefault:@""]; + BOOL error = false; + + // Variable to be able to rollback changes if something fails. + NSMutableDictionary* deletedValues = [NSMutableDictionary new]; + + NSLog(@"SecureStorage: Delete values with names %@ in collection %@", names, collection); + + for (NSString* name in names) { + if ([name length] == 0) { + continue; + } + + NSString* storedValue = [self getValue:name inCollection:collection]; + if (storedValue == nil) { + continue; + } + + OSStatus status = [self deleteName:name fromCollection:collection]; + + if (status != errSecSuccess && status != errSecItemNotFound) { + NSLog(@"Error deleting entry with name %@ in collection %@. Status: %d", name, collection, (int) status); + error = true; + + // Rollback. + for (NSString *name in deletedValues) { + [self addName:name withValue:deletedValues[name] inCollection:collection]; + } + } else { + deletedValues[name] = storedValue; + } + } + + CDVPluginResult* pluginResult; + if (error) { + pluginResult = [CDVPluginResult resultWithStatus:CDVCommandStatus_ERROR + messageAsString:@"Error deleting one or more values from secure storage."]; + } else { + pluginResult = [CDVPluginResult resultWithStatus:CDVCommandStatus_OK]; + } + + [self.commandDelegate sendPluginResult:pluginResult callbackId:command.callbackId]; +} + +- (OSStatus)deleteName:(NSString*)name fromCollection:(NSString*)collection { + NSDictionary* query = @{ + (id)kSecClass: (id)kSecClassGenericPassword, + (id)kSecAttrAccount: name, + (id)kSecAttrService: collection, + }; + + return SecItemDelete((CFDictionaryRef)query); +} + +- (void)deleteCollection:(CDVInvokedUrlCommand*)command { + NSString* collection = [command argumentAtIndex:0 withDefault:@""]; + + if ([collection length] == 0) { + NSLog(@"SecureStorage: Collection cannot be empty in deleteCollection"); + CDVPluginResult* pluginResult = [CDVPluginResult resultWithStatus:CDVCommandStatus_OK]; + [self.commandDelegate sendPluginResult:pluginResult callbackId:command.callbackId]; + + return; + } + + NSLog(@"SecureStorage: Delete all values in collection %@", collection); + + NSDictionary* query = @{ + (id)kSecClass: (id)kSecClassGenericPassword, + (id)kSecAttrService: collection, + }; + + OSStatus status = SecItemDelete((CFDictionaryRef)query); + + CDVPluginResult* pluginResult; + if (status == errSecSuccess || status == errSecItemNotFound) { + pluginResult = [CDVPluginResult resultWithStatus:CDVCommandStatus_OK]; + } else { + NSLog(@"Error deleting all values in collection %@. Status: %d", collection, (int) status); + pluginResult = [CDVPluginResult resultWithStatus:CDVCommandStatus_IO_EXCEPTION + messageAsString:@"Error deleting values from secure storage."]; + } + + [self.commandDelegate sendPluginResult:pluginResult callbackId:command.callbackId]; +} + +@end diff --git a/cordova-plugin-moodleapp/src/ts/index.ts b/cordova-plugin-moodleapp/src/ts/index.ts index 27a15ad10..c95a35f4d 100644 --- a/cordova-plugin-moodleapp/src/ts/index.ts +++ b/cordova-plugin-moodleapp/src/ts/index.ts @@ -13,9 +13,11 @@ // limitations under the License. import { SystemUI } from './plugins/SystemUI'; +import { SecureStorage } from './plugins/SecureStorage'; const api: MoodleAppPlugins = { systemUI: new SystemUI(), + secureStorage: new SecureStorage(), }; // This is necessary to work around the default transpilation behavior, diff --git a/cordova-plugin-moodleapp/src/ts/plugins/SecureStorage.ts b/cordova-plugin-moodleapp/src/ts/plugins/SecureStorage.ts new file mode 100644 index 000000000..26e15a65a --- /dev/null +++ b/cordova-plugin-moodleapp/src/ts/plugins/SecureStorage.ts @@ -0,0 +1,85 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * Allows retrieving and storing items in a secure storage. + */ +export class SecureStorage { + + /** + * Get one or more values. + * + * @param names Names of the values to get. + * @param collection The collection where the values are stored. + * @returns Object with name -> value. If a name isn't found it won't be included in the result. + */ + async get(names: string | string[], collection: string): Promise> { + if (typeof names === 'string') { + names = [names]; + } + + return new Promise((resolve, reject) => { + cordova.exec(resolve, reject, 'SecureStorage', 'get', [names, collection]); + }); + } + + /** + * Set one or more values. + * + * @param data Object with values to store, in format name -> value. Null or undefined valid values will be ignored. + * @param collection The collection where to store the values. + */ + async store(data: Record, collection: string): Promise { + for (const name in data) { + const value = data[name]; + if (value === undefined || value === null) { + delete data[name]; + } else if (typeof value !== 'string') { + throw new Error(`SecureStorage: Invalid value for ${name}. Expected string, received ${typeof value}`); + } + } + + await new Promise((resolve, reject) => { + cordova.exec(resolve, reject, 'SecureStorage', 'store', [data, collection]); + }); + } + + /** + * Delete one or more values. + * + * @param names Names to delete. + * @param collection The collection where to delete the values. + */ + async delete(names: string | string[], collection: string): Promise { + if (typeof names === 'string') { + names = [names]; + } + + await new Promise((resolve, reject) => { + cordova.exec(resolve, reject, 'SecureStorage', 'delete', [names, collection]); + }); + } + + /** + * Delete all values for a certain collection. + * + * @param collection The collection to delete. + */ + async deleteCollection(collection: string): Promise { + await new Promise((resolve, reject) => { + cordova.exec(resolve, reject, 'SecureStorage', 'deleteCollection', [collection]); + }); + } + +} diff --git a/cordova-plugin-moodleapp/types/index.d.ts b/cordova-plugin-moodleapp/types/index.d.ts index 77722c985..780b56caa 100644 --- a/cordova-plugin-moodleapp/types/index.d.ts +++ b/cordova-plugin-moodleapp/types/index.d.ts @@ -13,11 +13,13 @@ // limitations under the License. import { SystemUI } from '../src/ts/plugins/SystemUI'; +import { SecureStorage } from '../src/ts/plugins/SecureStorage'; declare global { interface MoodleAppPlugins { systemUI: SystemUI; + secureStorage: SecureStorage; } interface Cordova {