Skip to content
Snippets Groups Projects
AgentStore.ts 17.92 KiB
import { makeAutoObservable, runInAction } from "mobx";
import {
  Agent,
  AutoAcceptCredential,
  AutoAcceptProof,
  BasicMessageEventTypes,
  BasicMessageRole,
  BasicMessageStateChangedEvent,
  ConnectionsModule,
  ConsoleLogger,
  CredentialsModule,
  DidsModule,
  HttpOutboundTransport,
  InitConfig,
  KeyDerivationMethod,
  KeyDidRegistrar,
  KeyDidResolver,
  LogLevel,
  MediationRecipientModule,
  PeerDidRegistrar,
  PeerDidResolver,
  ProofsModule,
  V2CredentialProtocol,
  V2ProofProtocol,
  W3cCredentialService,
  W3cCredentialsModule,
  WsOutboundTransport,
} from "@aries-framework/core";
import { agentDependencies } from "@aries-framework/react-native";
import {
  AnonCredsCredentialFormatService,
  AnonCredsModule,
  AnonCredsProofFormatService,
  LegacyIndyProofFormatService,
  V1CredentialProtocol,
  V1ProofProtocol,
} from "@aries-framework/anoncreds";
import {
  IndyVdrAnonCredsRegistry,
  IndyVdrIndyDidRegistrar,
  IndyVdrIndyDidResolver,
  IndyVdrModule,
} from "@aries-framework/indy-vdr";
import { AnonCredsRsModule } from "@aries-framework/anoncreds-rs";
import Config from "react-native-config";
import { anoncreds } from "@hyperledger/anoncreds-react-native";
import { indyVdr } from "@hyperledger/indy-vdr-react-native";
import { AskarModule } from "@aries-framework/askar";
import { ariesAskar } from "@hyperledger/aries-askar-react-native";
import uuid from "react-native-uuid";
import indyLedgers from "src/configs/ledgers/indy";
import { getMnemonicArrayFromWords } from "src/utils/generic";
import {
  getGuid,
  getImapConfig,
  getPasscode,
  getPassphrase,
  setGuid,
  setPasscode,
  setPassphrase,
} from "src/utils/keychain";
import { RootStore } from "src/store/rootStore";
// @ts-ignore
import argon2 from "react-native-argon2";
import { salt } from "src/constants/constants";
import {
  WalletConfig,
  WalletExportImportConfig,
} from "@aries-framework/core/build/types";
import { LegacyIndyCredentialFormatService } from "@aries-framework/anoncreds/build/formats/LegacyIndyCredentialFormatService";
import { infoToast, successToast, warningToast } from "src/utils/toast";
import { Buffer } from "buffer";
import Realm from "realm";
import MimeParser from "@vereign/lib-mime";
import notifee, {
  AndroidImportance,
  AuthorizationStatus,
} from "@notifee/react-native";
import Email from "../db-models/Email";
import TrustedEmailSender from "../db-models/TrustedEmailSender";
import axios from "axios";
import { NativeModules } from "react-native";
import { addHours } from "date-fns";
import { JsonLdCredentialFormatService } from "../credo/JsonLdCredentialFormatService";

const { VereignImapModule } = NativeModules;

class AgentStore {
  private _rootStore: RootStore;
  private _agent: Agent | null = null;
  private _realm!: Realm;

  public get agent(): Agent {
    return this._agent as Agent;
  }
  private _navigation!: any;
  public get agentCreated() {
    return !!this._agent;
  }
  public injectNavigation = (navigation: any) => {
    this._navigation = navigation;
  };

  public injectRealm = (realm: Realm) => {
    this._realm = realm;
  };
  public get active(): boolean {
    return this._agent?.isInitialized || false;
  }

  constructor(rootStore: RootStore) {
    this._rootStore = rootStore;
    makeAutoObservable(this);
    this.generateGuidAndPassphraseIfNotPresented();
  }

  public generateGuidAndPassphraseIfNotPresented = async () => {
    const guid = await getGuid();
    if (!guid) {
      const guid = uuid.v4() as string;
      const mnemonicWordsList = getMnemonicArrayFromWords(8);
      const mnemonic = mnemonicWordsList.join(" ");
      await setPassphrase(mnemonic);
      await setGuid(guid);
    }
  };

  public createAgent = async () => {
    const guid = await getGuid();
    const passcode = await getPasscode();

    if (!passcode) {
      return; // it's not possible to create an agent without passcode
    }

    const config: InitConfig = {
      label: guid,
      logger: new ConsoleLogger(LogLevel.test),
      walletConfig: {
        storage: {
          type: "sqlite",
        },
        id: guid,
        key: passcode,
        keyDerivationMethod: KeyDerivationMethod.Argon2IMod,
      },
      endpoints: [],
    };

    const legacyIndyCredentialFormatService =
      new LegacyIndyCredentialFormatService();
    const legacyIndyProofFormatService = new LegacyIndyProofFormatService();

    const modules = {
      connections: new ConnectionsModule({
        autoAcceptConnections: true,
      }),
      credentials: new CredentialsModule({
        autoAcceptCredentials: AutoAcceptCredential.ContentApproved,
        credentialProtocols: [
          new V1CredentialProtocol({
            indyCredentialFormat: legacyIndyCredentialFormatService,
          }),
          new V2CredentialProtocol({
            credentialFormats: [
              legacyIndyCredentialFormatService,
              new AnonCredsCredentialFormatService(),
              new JsonLdCredentialFormatService()
            ],
          }),
        ],
      }),
      proofs: new ProofsModule({
        autoAcceptProofs: AutoAcceptProof.ContentApproved,
        proofProtocols: [
          new V1ProofProtocol({
            indyProofFormat: legacyIndyProofFormatService,
          }),
          new V2ProofProtocol({
            proofFormats: [
              legacyIndyProofFormatService,
              new AnonCredsProofFormatService(),
            ],
          }),
        ],
      }),
      anoncreds: new AnonCredsModule({
        registries: [new IndyVdrAnonCredsRegistry()],
        autoCreateLinkSecret: true,
      }),
      anoncredsRs: new AnonCredsRsModule({
        anoncreds,
        autoCreateLinkSecret: true,
      }),
      indyVdr: new IndyVdrModule({
        indyVdr,
        networks: indyLedgers,
      }),
      dids: new DidsModule({
        registrars: [
          new IndyVdrIndyDidRegistrar(),
          new KeyDidRegistrar(),
          new PeerDidRegistrar(),
        ],
        resolvers: [
          new IndyVdrIndyDidResolver(),
          new KeyDidResolver(),
          new PeerDidResolver(),
        ],
      }),
      askar: new AskarModule({
        ariesAskar,
      }),
      mediationRecipient: new MediationRecipientModule({
        // mediatorPickupStrategy: MediatorPickupStrategy.Implicit,
        mediatorInvitationUrl: Config.MEDIATOR_URL,
      }),
      w3c: new W3cCredentialsModule()
    };

    const newAgent = new Agent({
      config,
      dependencies: agentDependencies,
      modules,
    });

    const w3cCredentialService = newAgent.context.dependencyManager.resolve(W3cCredentialService);
    // TODO Hack dor demo purpose
    // @ts-ignore
    w3cCredentialService.verifyCredential = () => ({ isValid: true })


    newAgent.registerOutboundTransport(new WsOutboundTransport());
    newAgent.registerOutboundTransport(new HttpOutboundTransport());

    runInAction(() => {
      this._agent = newAgent;
    });

    console.log("staring to listen for messages");
    newAgent.events.on(
      BasicMessageEventTypes.BasicMessageStateChanged,
      (e: BasicMessageStateChangedEvent) => this._processAgentMessage(e)
    );
  };

  public startAgent = async () => {
    console.debug("AGENT_STORE: initializing agent");

    if (!this._agent) {
      throw new Error("Agent has not been created");
    }

    if (!this._agent.isInitialized) {
      console.debug(
        "AGENT_STORE: Wallet is not initialized. call agent.initialize()"
      );
      await this._agent.initialize();
    }

    if (!this.agent.isInitialized) {
      console.debug("AGENT_STORE: Wallet is NOT initialized");
      throw new Error("agent not initialized");
    } else {
      console.debug("AGENT_STORE: Wallet is initialized");
    }
  };

  private _processAgentMessage = async (ev: BasicMessageStateChangedEvent) => {
    if (ev.payload.basicMessageRecord.role === BasicMessageRole.Receiver) {
      let emailContent = ev.payload.basicMessageRecord.content;
      try {
        emailContent = Buffer.from(emailContent, "base64").toString();
      } catch (e: any) {
        console.error("Error decoding base64", e);
        warningToast("Email content is not base64 encoded");
      }

      try {
        let parser = new MimeParser(emailContent);
        const val = (header: string[] | null) => {
          return (header && header[0]) || "";
        };

        const svdxBaseUrlHeader = parser.getGlobalHeaderValue("x-svdx-hostname");
        const svdxBaseUrl =  (val(svdxBaseUrlHeader).startsWith("https://") || val(svdxBaseUrlHeader).startsWith("http://")) ? val(svdxBaseUrlHeader) : `https://${svdxBaseUrlHeader}`;
        
        console.log(`base url parsed from mime is: ${svdxBaseUrl}`);

        const messageIdHeader = parser.getGlobalHeaderValue("message-id");
        const messageId = val(messageIdHeader);

        const svdxIdHeader = parser.getGlobalHeaderValue("x-message-id");
        const svdxId = val(svdxIdHeader);

        const receivedDate = new Date();

        const createdAtHeader = parser.getGlobalHeaderValue("date");
        let createdAt = receivedDate;
        if (createdAtHeader) {
          createdAt = new Date(val(createdAtHeader) as string);
        }

        const fromHeader = parser.getGlobalHeaderValue("from");
        const from = val(fromHeader);

        const toHeader = parser.getGlobalHeaderValue("to");
        const to = toHeader && toHeader.length ? toHeader.join(", ") : "";

        const ccHeader = parser.getGlobalHeaderValue("cc");
        const cc = ccHeader && ccHeader.length ? ccHeader.join(", ") : "";

        const bccHeader = parser.getGlobalHeaderValue("bcc");
        const bcc = bccHeader && bccHeader.length ? bccHeader.join(", ") : "";

        const subjectHeader = parser.getGlobalHeaderValue("subject");
        const subject = val(subjectHeader);

        const realmRecordId = new Realm.BSON.ObjectId();

        this._realm.write(() => {
          const newEmail: Partial<Email> = {
            _id: realmRecordId,
            messageId: messageId,
            createdAt: createdAt,
            receivedDate: receivedDate,

            type: "svdx-mail",

            from: from,
            to: to,
            cc: cc,
            bcc: bcc,
            subject: subject,
            read: false,

            svdxMime: emailContent,
            svdxImapSaved: false,
            svdxBaseUrl: svdxBaseUrl,
            svdxMessageId: svdxId,

            sealUrl: null,
          };
          this._realm.create("Email", newEmail);
        });

        infoToast("Received new message");

        if (this.isTrustedSender(from)) {
          await this._saveEmail(svdxBaseUrl, svdxId, realmRecordId, emailContent);
        }

        this._displayNewEmailReceived(from, subject, realmRecordId.toString());
      } catch (e) {
        warningToast("Email is received but it can not be decoded");
        return;
      }
    }
  };

  public isTrustedSender = (sender: string) => {
    const emails = this._realm.objects("TrustedEmailSender")
      .filtered('sender == $0', sender);
    return emails.length > 0;
  }

  public trustSender = (sender: string) => {
    if (this.isTrustedSender(sender)) {
      console.warn("Sender is already trusted");
      return;
    }
    const realmRecordId = new Realm.BSON.ObjectId();
    this._realm.write(() => {
      const newTrustedEmailSender: Partial<TrustedEmailSender> = {
        _id: realmRecordId,
        sender: sender
      }
      this._realm.create('TrustedEmailSender', newTrustedEmailSender);
    });
  }

  private _saveEmail = async (
    svdxBaseUrl: string,
    svdxMessageId: string,
    realmId: Realm.BSON.ObjectId,
    mime: string
  ) => {
    try {
      const config = await getImapConfig();
      if (config) {
        if (!(await this._checkCanSaveEmail(svdxBaseUrl,svdxMessageId))) return;

        VereignImapModule.saveMessage(
          config!.host,
          config!.username,
          config!.password,
          mime,
          (success: boolean, error: any) => {
            if (success) {
              successToast("Email is saved");
              const email = this._realm.objectForPrimaryKey("Email", realmId);

              if (email) {
                this._realm.write(() => {
                  email.svdxImapSaved = true;
                });
                this._notifySVDXServer(svdxBaseUrl,svdxMessageId);
              }
            }
            if (error) {
              warningToast("Something went wrong " + error);
            }
          }
        );
      }
    } catch (e: any) {
      warningToast(e.message);
    }
  };
  private _checkCanSaveEmail = async (svdxBaseUrl: string,svdxMessageId: string) => {
    try {
      console.log(`Sending request to ${svdxBaseUrl}/v1/messages/status for message with id ${svdxMessageId}`);
      const response = await axios({
        url: `${svdxBaseUrl}/v1/message/status`,
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        data: {
          messageId: svdxMessageId,
        },
      });

      // Check for HTTP 200 status
      if (response.status === 200) {
        console.log("Message has not been delivered. All is ok");
        return true;
      }
      return false;
    } catch (error: any) {
      if (error.response && error.response.status === 404) {
        // Handle the case where the message has been delivered already in another way
        console.log("Message has been delivered already in another way");
      } else {
        // Handle other errors
        console.error("An error occurred:", error.message);
      }
    }
    return false;
  };

  private _notifySVDXServer = async (svdxBaseUrl: string,svdxMessageId: string) => {
    try {
      console.log(`Sending request to ${svdxBaseUrl}/v1/messages/read for message with id ${svdxMessageId}`);
      await axios({
        url: `${svdxBaseUrl}/v1/message/read`,
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        data: {
          messageId: svdxMessageId,
        },
      });
      console.log("Message has been marked as read");
    } catch (e: any) {
      console.error(
        "Something went wrong during notifying SVDX server about read message",
        e.message
      );
    }
  };

  private _displayNewEmailReceived = async (
    from: string,
    subject: string,
    realmId: string
  ) => {
    const authResult = await notifee.requestPermission();
    if (authResult.authorizationStatus !== AuthorizationStatus.AUTHORIZED) {
      return;
    }

    const channelId = await notifee.createChannel({
      id: "default",
      name: "Default Channel",
      importance: AndroidImportance.HIGH,
    });
    // Display a notification
    await notifee.displayNotification({
      title: `New Email from ${from}`,
      body: `Subject: ${subject || "[No subject]"}`,
      data: {
        type: "new-email-received",
        realmId: realmId,
      },
      android: {
        channelId,
        // smallIcon: 'ic_launcher', // optional, defaults to 'ic_launcher'.
        // pressAction is needed if you want the notification to open the app when pressed
        pressAction: {
          id: "default",
        },
      },
    });
  };

  public checkNotSavedEmailMessages = async () => {
    // get all today's not saved messages
    const today = new Date();
    const emails = this._realm
      .objects<Email>("Email")
      .filtered(
        "type == $0 && svdxImapSaved == false AND receivedDate > $1",
        "svdx-mail",
        addHours(today, -6)
      );

    for (let i = 0; i < emails.length; i++) {
      const mailRow = emails[i];
      if (this.isTrustedSender(mailRow.from)) {
        await this._saveEmail(
          mailRow.svdxBaseUrl,
          mailRow.svdxMessageId!,
          mailRow._id!,
          mailRow.svdxMime!
        );
      }
    }
  };

  public stopAgent = async () => {
    console.debug("Stop agent");

    // TODO
    console.warn("As soon as closing pool is not implemented here:");
    console.warn(
      "https://github.com/hyperledger/aries-framework-javascript/blob/main/packages/indy-vdr/src/pool/IndyVdrPool.ts#L83"
    );
    console.warn(
      "Closing agent breaking initializing after shutdown and is disabled"
    );
    // await this._agent?.shutdown();
  };

  public importWallet = async (
    mnemonic: string,
    walletBackupFilePath: string
  ) => {
    await this.createAgent();

    const { encodedHash } = await argon2(mnemonic, salt, {
      iterations: 5,
      memory: 16 * 1024,
      parallelism: 2,
      hashLength: 20,
      mode: "argon2i",
    });

    const walletConfig = this.agent.wallet.walletConfig as WalletConfig;

    const importConfig: WalletExportImportConfig = {
      key: encodedHash,
      path: walletBackupFilePath,
    };

    await this.agent.wallet.import(walletConfig, importConfig);
    await this.agent.wallet.initialize(walletConfig);
    await this.agent.initialize();
    await setPassphrase(mnemonic);
    this._rootStore.setAuthenticated(true);
  };

  public exportWallet = async (toPath: string) => {
    if (!this._agent) {
      throw new Error("Agent has not been created");
    }

    const passphrase = getPassphrase();

    const result = await argon2(passphrase, salt, {
      iterations: 5,
      memory: 16 * 1024,
      parallelism: 2,
      hashLength: 20,
      mode: "argon2i",
    });

    const { encodedHash } = result;

    const exportConfig: WalletExportImportConfig = {
      key: encodedHash,
      path: toPath,
    };
    await this._agent.wallet.export(exportConfig);
  };

  public changePin = async (newPin: string) => {
    if (!this._agent) {
      throw new Error("Agent has not been created");
    }
    const guid = await getGuid();
    const oldPasscode = await getPasscode();

    await this._agent.shutdown();
    await this._agent.wallet.rotateKey({
      id: guid,
      key: oldPasscode,
      rekey: newPin,
    });
    await this._agent.initialize();
    await setPasscode(newPin);
  };

  public deleteWalletData = async () => {
    if (!this._agent) return;

    await this._agent.wallet.delete();
    await this._agent.shutdown();
    this._agent = null;
  };
}

export type { AgentStore };

export default AgentStore;