diff --git a/frontend/app/README.md b/frontend/app/README.md index 3095d098..a9058ee2 100644 --- a/frontend/app/README.md +++ b/frontend/app/README.md @@ -120,6 +120,19 @@ Then start the app normally as you would pnpm run:android ``` +**This alone will not make auth work**, only the login flow. + +If you need to test login, you have to disable live reload: + +```sh +pnpm ionic capacitor run android +``` + +_**CAUTION:** a remote chrome debugging session can in some +cases hijack the device's ADB connection. If the connection +fails for no obvious reason, close chrome and uninstall the +app, then try again._ + #### iOS On Android you will need to change the `custom_url_scheme` value in diff --git a/frontend/app/src/app/modules/profile/id-cards.provider.ts b/frontend/app/src/app/modules/profile/id-cards.provider.ts index 8f56d42f..2d02f002 100644 --- a/frontend/app/src/app/modules/profile/id-cards.provider.ts +++ b/frontend/app/src/app/modules/profile/id-cards.provider.ts @@ -1,10 +1,11 @@ import {Injectable} from '@angular/core'; import {SCIdCard, SCThingOriginType, SCThingType, SCUserConfiguration} from '@openstapps/core'; -import {from, Observable, of} from 'rxjs'; +import {from, of, Observable} from 'rxjs'; import {AuthHelperService} from '../auth/auth-helper.service'; -import {mergeMap, filter, map, startWith} from 'rxjs/operators'; +import {mergeMap, concatWith, filter, map, startWith, catchError, tap} from 'rxjs/operators'; import {ConfigProvider} from '../config/config.provider'; import {HttpClient} from '@angular/common/http'; +import {EncryptedStorageProvider} from '../storage/encrypted-storage.provider'; @Injectable({providedIn: 'root'}) export class IdCardsProvider { @@ -12,18 +13,27 @@ export class IdCardsProvider { private authHelper: AuthHelperService, private config: ConfigProvider, private httpClient: HttpClient, + private encryptedStorageProvider: EncryptedStorageProvider, ) {} getIdCards(): Observable { const feature = this.config.config.app.features.extern?.['idCards']; const auth = this.authHelper.getProvider(feature?.authProvider ?? 'default'); + const storedIdCards = from( + this.encryptedStorageProvider.get('id-cards') as Promise, + ).pipe(filter(it => it !== undefined)); return auth.isAuthenticated$.pipe( mergeMap(isAuthenticated => isAuthenticated ? feature - ? from(auth.getValidToken()).pipe( - mergeMap(token => this.fetchIdCards(feature.url, token.accessToken)), + ? storedIdCards.pipe( + concatWith( + from(auth.getValidToken()).pipe( + mergeMap(token => this.fetchIdCards(feature.url, token.accessToken)), + catchError(() => storedIdCards), + ), + ), ) : auth.user$.pipe( filter(user => user !== undefined), @@ -31,19 +41,20 @@ export class IdCardsProvider { mergeMap(user => this.fetchFallbackIdCards(user)), startWith([]), ) - : // TODO: find a better solution here (async pipe stuff...) - of([]), + : of([]).pipe(tap(() => this.encryptedStorageProvider.delete('id-cards'))), ), ); } private fetchIdCards(url: string, token: string): Observable { - return this.httpClient.get(url, { - headers: { - Authorization: `Bearer ${token}`, - }, - responseType: 'json', - }); + return this.httpClient + .get(url, { + headers: { + Authorization: `Bearer ${token}`, + }, + responseType: 'json', + }) + .pipe(tap(idCards => this.encryptedStorageProvider.set('id-cards', idCards))); } private fetchFallbackIdCards(user: SCUserConfiguration): Observable { diff --git a/frontend/app/src/app/modules/profile/id-cards.spec.ts b/frontend/app/src/app/modules/profile/id-cards.spec.ts index 2c3fd398..4553a34b 100644 --- a/frontend/app/src/app/modules/profile/id-cards.spec.ts +++ b/frontend/app/src/app/modules/profile/id-cards.spec.ts @@ -4,6 +4,7 @@ import {HttpClient} from '@angular/common/http'; import {AuthHelperService} from '../auth/auth-helper.service'; import {BehaviorSubject, firstValueFrom, of} from 'rxjs'; import {SCAuthorizationProviderType} from '@openstapps/core'; +import {EncryptedStorageProvider} from '../storage/encrypted-storage.provider'; class FakeAuth { isAuthenticated$ = new BehaviorSubject(false); @@ -16,6 +17,7 @@ describe('IdCards', () => { let configProvider: ConfigProvider; let httpClient: HttpClient; let authHelper: AuthHelperService; + let encryptedStorageProvider: EncryptedStorageProvider; let fakeAuth: FakeAuth; beforeEach(() => { @@ -27,10 +29,14 @@ describe('IdCards', () => { fakeAuth = new FakeAuth(); authHelper = jasmine.createSpyObj('AuthHelperService', ['getProvider']); authHelper.getProvider = jasmine.createSpy().and.returnValue(fakeAuth); + encryptedStorageProvider = jasmine.createSpyObj('EncryptedStorageProvider', ['get', 'set', 'delete']); + encryptedStorageProvider.get = jasmine.createSpy().and.resolveTo(); + encryptedStorageProvider.set = jasmine.createSpy().and.resolveTo(); + encryptedStorageProvider.delete = jasmine.createSpy().and.resolveTo(); }); it('should emit undefined if not logged in', async () => { - const provider = new IdCardsProvider(authHelper, configProvider, httpClient); + const provider = new IdCardsProvider(authHelper, configProvider, httpClient, encryptedStorageProvider); expect(await firstValueFrom(provider.getIdCards())).toEqual([]); expect(authHelper.getProvider).toHaveBeenCalledOnceWith('fakeAuth' as SCAuthorizationProviderType); }); @@ -39,7 +45,7 @@ describe('IdCards', () => { fakeAuth.isAuthenticated$.next(true); httpClient.get = jasmine.createSpy().and.returnValue(of(['abc'])); fakeAuth.getValidToken = jasmine.createSpy().and.resolveTo({accessToken: 'fake-token'}); - const provider = new IdCardsProvider(authHelper, configProvider, httpClient); + const provider = new IdCardsProvider(authHelper, configProvider, httpClient, encryptedStorageProvider); expect(await firstValueFrom(provider.getIdCards())).toEqual(['abc' as never]); expect(authHelper.getProvider).toHaveBeenCalledOnceWith('fakeAuth' as SCAuthorizationProviderType); // eslint-disable-next-line unicorn/no-null @@ -52,7 +58,7 @@ describe('IdCards', () => { }); it('should react to logins', async () => { - const provider = new IdCardsProvider(authHelper, configProvider, httpClient); + const provider = new IdCardsProvider(authHelper, configProvider, httpClient, encryptedStorageProvider); const observable = provider.getIdCards(); expect(await firstValueFrom(observable)).toEqual([]); httpClient.get = jasmine.createSpy().and.returnValue(of(['abc'])); diff --git a/frontend/app/src/app/modules/storage/encrypted-storage.provider.ts b/frontend/app/src/app/modules/storage/encrypted-storage.provider.ts new file mode 100644 index 00000000..7a88e592 --- /dev/null +++ b/frontend/app/src/app/modules/storage/encrypted-storage.provider.ts @@ -0,0 +1,92 @@ +import {Injectable} from '@angular/core'; +import {StorageProvider} from './storage.provider'; +import {Capacitor} from '@capacitor/core'; +import {SecureStoragePlugin} from 'capacitor-secure-storage-plugin'; + +@Injectable({providedIn: 'root'}) +export class EncryptedStorageProvider { + constructor(private storageProvider: StorageProvider) {} + + /** + * Retrieve a large value from an encrypted storage + * Also returns undefined if a secure context is not available (i.e. web). + * @param key Unique identifier of the wanted resource in storage + * @returns The value of the resource, if found + */ + async get(key: string): Promise { + if (!Capacitor.isNativePlatform()) return undefined; + + try { + const jwt = JSON.parse((await SecureStoragePlugin.get({key: `stapps:key:${key}`})).value); + const aesKey = await crypto.subtle.importKey('jwk', jwt, {name: 'AES-GCM'}, true, [ + 'encrypt', + 'decrypt', + ]); + const iv = await this.storageProvider.get(`encrypted:${key}:iv`); + + const encryptedIdCards = await this.storageProvider.get(`encrypted:${key}`); + const decrypted = await crypto.subtle.decrypt({name: 'AES-GCM', iv}, aesKey, encryptedIdCards); + + const decompressionStream = new DecompressionStream('gzip'); + const writer = decompressionStream.writable.getWriter(); + writer.write(decrypted); + writer.close(); + + const decompressed = await new Response(decompressionStream.readable).arrayBuffer(); + return JSON.parse(new TextDecoder().decode(decompressed)); + } catch (error) { + console.warn(error); + return undefined; + } + } + + /** + * Store a large value in an encrypted storage + * Does nothing if a secure context is not available (i.e. web). + * @param key Unique identifier of the resource in storage + * @param value The value to store + * @returns A promise that resolves when the value is stored + */ + async set(key: string, value: T): Promise { + if (!Capacitor.isNativePlatform()) return undefined; + + try { + const compressionStream = new CompressionStream('gzip'); + const writer = compressionStream.writable.getWriter(); + writer.write(new TextEncoder().encode(JSON.stringify(value))); + writer.close(); + const encoded = await new Response(compressionStream.readable).arrayBuffer(); + + const iv = crypto.getRandomValues(new Uint8Array(16)); + const aesKey = await crypto.subtle.generateKey({name: 'AES-GCM', length: 256}, true, [ + 'encrypt', + 'decrypt', + ]); + + await Promise.all([ + SecureStoragePlugin.set({ + key: `stapps:key:${key}`, + value: JSON.stringify(await crypto.subtle.exportKey('jwk', aesKey)), + }), + this.storageProvider.put(`encrypted:${key}:iv`, iv), + ]); + + this.storageProvider.put( + `encrypted:${key}`, + await crypto.subtle.encrypt({name: 'AES-GCM', iv}, aesKey, encoded), + ); + } catch (error) { + alert(error); + } + } + + async delete(key: string): Promise { + if (!Capacitor.isNativePlatform()) return; + + await Promise.all([ + SecureStoragePlugin.remove({key: `stapps:key:${key}`}), + this.storageProvider.delete(`encrypted:${key}:iv`), + this.storageProvider.delete(`encrypted:${key}`), + ]); + } +}