teamdigitale/italia-app

View on GitHub
ts/features/lollipop/saga/index.ts

Summary

Maintainability
A
3 hrs
Test Coverage
import * as O from "fp-ts/lib/Option";
import { pipe } from "fp-ts/lib/function";
import * as T from "fp-ts/lib/Task";
import * as TE from "fp-ts/lib/TaskEither";
import { v4 as uuid } from "uuid";
import { put, select, call, delay } from "typed-redux-saga/macro";
import {
  deleteKey,
  generate,
  getPublicKey,
  PublicKey
} from "@pagopa/io-react-native-crypto";
import { Millisecond } from "@pagopa/ts-commons/lib/units";
import {
  lollipopKeyTagSelector,
  lollipopPublicKeySelector
} from "../store/reducers/lollipop";
import {
  lollipopKeyTagSave,
  lollipopRemovePublicKey,
  lollipopSetPublicKey
} from "../store/actions/lollipop";
import {
  KeyInfo,
  toBase64EncodedThumbprint,
  toCryptoError
} from "../utils/crypto";
import { DEFAULT_LOLLIPOP_HASH_ALGORITHM_SERVER } from "../utils/login";
import { sessionInvalid } from "../../../store/actions/authentication";
import { restartCleanApplication } from "../../../sagas/commons";

import { isMixpanelEnabled } from "../../../store/reducers/persistedPreferences";
import {
  buildEventProperties,
  trackLollipopKeyGenerationFailure,
  trackLollipopKeyGenerationSuccess
} from "../../../utils/analytics";
import { PublicSession } from "../../../../definitions/session_manager/PublicSession";
import { mixpanelTrack } from "../../../mixpanel";

const WAIT_A_BIT_AFTER_SESSION_EXPIRED = 1000 as Millisecond;

export function* generateLollipopKeySaga() {
  const maybeOldKeyTag = yield* select(lollipopKeyTagSelector);
  // Weather the user is logged in or not
  // we generate a key (if no one is present)
  // to have a key also for those users that update the app
  // and are already logged in.
  if (O.isNone(maybeOldKeyTag)) {
    const newKeyTag = uuid();
    yield* put(lollipopKeyTagSave({ keyTag: newKeyTag }));
    yield* call(cryptoKeyGenerationSaga, newKeyTag, maybeOldKeyTag);
  } else {
    try {
      // If we already have a keyTag, we check if there is
      // a public key tied with it.
      const publicKey = yield* call(getPublicKey, maybeOldKeyTag.value);
      yield* put(lollipopSetPublicKey({ publicKey }));
    } catch {
      // If there is no key it could be for two reasons:
      // - The user have a recent app and they logged out (the key is deleted).
      // - The user is logged in and is updating from an app version
      //    that didn't manage the key generation.
      // Having a key or an error in those cases is useful to show
      // the user an informative banner saying that their device
      // is not suitable for future version of IO.
      yield* call(cryptoKeyGenerationSaga, maybeOldKeyTag.value, O.none);
    }
  }
}

export function* deleteCurrentLollipopKeyAndGenerateNewKeyTag() {
  const maybeCurrentKeyTag = yield* select(lollipopKeyTagSelector);
  yield* call(deletePreviousCryptoKeyPair, maybeCurrentKeyTag);
  const newKeyTag = uuid();
  yield* put(lollipopKeyTagSave({ keyTag: newKeyTag }));
}

export function* checkLollipopSessionAssertionAndInvalidateIfNeeded(
  maybePublicKey: O.Option<PublicKey>,
  maybeSessionInformation: O.Option<PublicSession>
) {
  const lollipopCheckResult = pipe(
    maybeSessionInformation,
    O.chainNullableK(
      sessionInformation => sessionInformation.lollipopAssertionRef
    ),
    O.chain(sessionLollipopAssertionRef =>
      pipe(
        maybePublicKey,
        O.map(publicKey =>
          pipe(
            toBase64EncodedThumbprint(publicKey),
            publicKeyThumbprint =>
              `${DEFAULT_LOLLIPOP_HASH_ALGORITHM_SERVER}-${publicKeyThumbprint}`,
            localLollipopAssertionRef =>
              localLollipopAssertionRef === sessionLollipopAssertionRef
          )
        )
      )
    ),
    O.getOrElse(() => false)
  );

  if (!lollipopCheckResult) {
    void mixpanelTrack(
      "LOGIN_UNEXPECTED_REQUEST_ID",
      buildEventProperties("KO", undefined)
    );
    yield* put(sessionInvalid());
    // We want to take a little time before restarting the application
    // to let the action sessionInvalid be dispatched and handled.
    yield* delay(WAIT_A_BIT_AFTER_SESSION_EXPIRED);
    yield* call(restartCleanApplication);
    return false;
  }

  return true;
}

/**
 * Generates a new crypto key pair.
 */
function* cryptoKeyGenerationSaga(
  keyTag: string,
  previousKeyTag: O.Option<string>
) {
  // Every new login we need to regenerate a brand new key pair.
  yield* call(deletePreviousCryptoKeyPair, previousKeyTag);
  yield* call(generateCryptoKeyPair, keyTag);
}

/**
 * Deletes a previous saved crypto key pair.
 */
function* deletePreviousCryptoKeyPair(keyTag: O.Option<string>) {
  if (O.isSome(keyTag)) {
    yield* call(deleteCryptoKeyPair, keyTag.value);
  }
}

/**
 * Deletes the crypto key pair corresponding to the provided `keyTag`.
 */
function* deleteCryptoKeyPair(keyTag: string) {
  // Key is persisted even after uninstalling the application on iOS.
  const keyAlreadyExistsOnKeystore = yield* call(checkPublicKeyExists, keyTag);

  if (keyAlreadyExistsOnKeystore) {
    try {
      yield* call(deleteKey, keyTag);
      yield* put(lollipopRemovePublicKey());
    } catch (e) {
      const mixPanelEnabled = yield* select(isMixpanelEnabled);
      if (mixPanelEnabled) {
        const { message } = toCryptoError(e);
        yield* call(trackLollipopKeyGenerationFailure, message);
      }
    }
  }
}

const checkPublicKeyExists = (keyTag: string) =>
  pipe(
    TE.tryCatch(
      () => getPublicKey(keyTag),
      () => false
    ),
    TE.map(_ => true),
    TE.getOrElse(() => T.of(false))
  )();

/**
 * Generates a new crypto key pair.
 */
function* generateCryptoKeyPair(keyTag: string) {
  try {
    // Remove an already existing key with the same tag.
    yield* call(deleteCryptoKeyPair, keyTag);

    const publicKey = yield* call(generate, keyTag);
    yield* put(lollipopSetPublicKey({ publicKey }));
    const mixPanelEnabled = yield* select(isMixpanelEnabled);
    if (mixPanelEnabled) {
      yield* call(trackLollipopKeyGenerationSuccess, publicKey.kty);
    }
  } catch (e) {
    const mixPanelEnabled = yield* select(isMixpanelEnabled);
    if (mixPanelEnabled) {
      const { message } = toCryptoError(e);
      yield* call(trackLollipopKeyGenerationFailure, message);
    }
  }
}

export function* getKeyInfo() {
  const keyTag = yield* select(lollipopKeyTagSelector);
  const publicKey = yield* select(lollipopPublicKeySelector);
  return yield* call(generateKeyInfo, keyTag, publicKey);
}

export const generateKeyInfo = (
  maybeKeyTag: O.Option<string>,
  maybePublicKey: O.Option<PublicKey>
) =>
  pipe(
    maybeKeyTag,
    O.chain(keyTag =>
      pipe(
        maybePublicKey,
        O.map(publicKey => keyInfoFromKeyTagAndPublicKey(keyTag, publicKey))
      )
    ),
    O.getOrElse(defaultKeyInfo)
  );

const keyInfoFromKeyTagAndPublicKey = (
  keyTag: string,
  publicKey: PublicKey
): KeyInfo => ({
  keyTag,
  publicKey,
  publicKeyThumbprint: toBase64EncodedThumbprint(publicKey)
});

const defaultKeyInfo = (): KeyInfo => ({
  keyTag: undefined,
  publicKey: undefined,
  publicKeyThumbprint: undefined
});