MOBILE-3839 ios: Implement secure storage plugin for iOS

main
Dani Palou 2023-08-03 08:34:14 +02:00
parent b8071a6946
commit f93f2a973a
6 changed files with 328 additions and 0 deletions

View File

@ -13,4 +13,14 @@
</config-file> </config-file>
<source-file src="src/android/SystemUI.java" target-dir="src/com/moodle/moodlemobile" /> <source-file src="src/android/SystemUI.java" target-dir="src/com/moodle/moodlemobile" />
</platform> </platform>
<platform name="ios">
<config-file target="config.xml" parent="/*">
<feature name="SecureStorage">
<param name="ios-package" value="SecureStorage" />
</feature>
</config-file>
<header-file src="src/ios/SecureStorage.h" />
<source-file src="src/ios/SecureStorage.m" />
</platform>
</plugin> </plugin>

View File

@ -0,0 +1,11 @@
#import <Foundation/Foundation.h>
#import <Cordova/CDVPlugin.h>
@interface SecureStorage : CDVPlugin {}
- (void)get:(CDVInvokedUrlCommand*)command;
- (void)store:(CDVInvokedUrlCommand*)command;
- (void)delete:(CDVInvokedUrlCommand*)command;
- (void)deleteCollection:(CDVInvokedUrlCommand*)command;
@end

View File

@ -0,0 +1,218 @@
#import <Foundation/Foundation.h>
#import <Cordova/CDVPlugin.h>
#import <Cordova/CDVPluginResult.h>
#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

View File

@ -13,9 +13,11 @@
// limitations under the License. // limitations under the License.
import { SystemUI } from './plugins/SystemUI'; import { SystemUI } from './plugins/SystemUI';
import { SecureStorage } from './plugins/SecureStorage';
const api: MoodleAppPlugins = { const api: MoodleAppPlugins = {
systemUI: new SystemUI(), systemUI: new SystemUI(),
secureStorage: new SecureStorage(),
}; };
// This is necessary to work around the default transpilation behavior, // This is necessary to work around the default transpilation behavior,

View File

@ -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<Record<string, string>> {
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<string, string>, collection: string): Promise<void> {
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<void> {
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<void> {
await new Promise((resolve, reject) => {
cordova.exec(resolve, reject, 'SecureStorage', 'deleteCollection', [collection]);
});
}
}

View File

@ -13,11 +13,13 @@
// limitations under the License. // limitations under the License.
import { SystemUI } from '../src/ts/plugins/SystemUI'; import { SystemUI } from '../src/ts/plugins/SystemUI';
import { SecureStorage } from '../src/ts/plugins/SecureStorage';
declare global { declare global {
interface MoodleAppPlugins { interface MoodleAppPlugins {
systemUI: SystemUI; systemUI: SystemUI;
secureStorage: SecureStorage;
} }
interface Cordova { interface Cordova {