【Hyperledger Indy/Aries】AFJでReact Native製Holder Agentを作りVCモデルを回す(後編)

こんにちは。GMOグローバルサイン・ホールディングスCTO室で分散型IDの研究開発をしている神沼@t_kanumaです。

この記事は以下の記事の続編です。

この記事では、AFJを利用しながら、React Native環境でAndroidアプリとして簡易なHolder Agentを作り、VCモデルを回す試みの様子をお届けします。

環境

下図の通り環境を構築します。ポイントを列挙します。

  • Holder Agentについて
    • Holder AgentはAndroidアプリであり、筆者のスマートフォンにインストールして動作させます。
    • UIコンポーネントライブラリにReact Native Paperを、画面遷移ライブラリにReact Navigationを使います。
    • HolderのWalletのソフトウェアは、AFJが内包しているSQLiteです。
  • Holder Agent以外について
    • Holder Agent以外の要素は前編と同様Dockerコンテナです。
    • AWS上にシンプルにEC2インスタンスを1つ建て、そこにDockerをインストールし、前編と同様に各コンテナを建てて繋ぎます。
  • 通信について
    • HolderからAWSへ、またAWSからHolderへの通信はインターネットを通ります。
    • MediatorからHolder方向の通信のみWebSocketになるよう設定します。
  • 以下、SSIに関するOSSのバージョンです。

準備

Issuerに関するLedger上のTransaction

前編同様、以下のIssuerに関するTransactionについては、あらかじめLedgerに作成しておきます。詳細は前編をご参照ください。

Von Networkのコードの微調整

HolderはDID Exchange ProtocolでのConnection生成、もしくはProof提示に失効状態の検証を含む要求をVerifierから受けた場合、Indy Ledgerにアクセスすると考えます。今回は後者のためにAWS上のVon Networkにアクセスします。

アクセスに必要なGenesis Transactionsデータ内の各Indy NodeのIPアドレスが、EC2インスタンスのPublic IPアドレスになるよう、以下のようにVon Networkのシェルスクリプト(von-network/manage)を微修正しました。

# export DOCKERHOST=${APPLICATION_URL-$(docker run --rm --net=host eclipse/che-ip)}
export DOCKERHOST=<EC2インスタンスのPublic IPアドレス>

AFJ ExtensionのReact Hooksパッケージの利用方法

GitHubリポジトリに簡易な開発ガイドがあり、それを参考に実装に利用しました。

ガイドの通りですが、このパッケージは関数コンポーネント内から使える以下の便利なカスタムフック群を提供しています。(内部でアプリケーションスコープのコンテキストに紐付けたAgentインスタンスをラップする形で機能を提供しています。)

import AgentProvider, {
  useAgent,
  useConnections,
  useConnectionById,
  useConnectionByState,
  useCredentials,
  useCredentialById,
  useCredentialByState,
  useProofs,
  useProofById,
  useProofByState,
} from '@aries-framework/react-hooks'

またAries Mobile Agent React Nativeの以下のコードも利用方法として参考になります。

Holder Agentの開発

メインパートです。

画面

簡易な参照系の画面を3つ作りました。(コードは後述します。)

Connectio一覧ページ

Connectionを張った相手のラベル、Peer DID、Connection ID、状態、Connection生成日時を表示しています。

発行済みVC一覧ページ

どのConnection上での発行なのかを示すConnection ID、状態、おおよその発行した時間を表示しています。画像は適当なモノです。

Proof提示履歴一覧ページ

Proofのラベル、どのConnection上での提示なのかを示すConnection ID、状態、おおよそのProofを提示した時間を表示しています。(Proofの検証結果はありません。HolderがProofの検証結果を知ることはProof Present Protocol 1.0のスコープ外であるためです。)

プロジェクトの構築と構成

構築手順

詳細は各リンク先をご参照ください。

  1. React Nativeの開発環境のセットアップ
  2. 新規プロジェクトの作成 
npx react-native init <プロジェクト名> --template react-native-template-typescript
  1. プロジェクト内へのindy-sdkの配備
  2. AFJのインストール
  3. AFJ ExtensionからReact Hooksパッケージのインストール
npm install @aries-framework/react-hooks
  1. React Native Paperのインストール
  2. React Navigationのインストール

プロジェクト構成

以下、簡略化した構成図です。

  • App.tsx: エントリポイントです。ここでAgentの初期化とEvent Listener群の登録をしています。
  • src/navigatorsディレクトリ
    • React Navigationを使った画面遷移に関するコードを置いています。
    • index.ts: App.tsxでimportするRootNavigatorコンポーネントを定義しています。
    • BottomTab.tsx: RootNavigatorの中で展開されるコンポーネントです。画面下部に前述した3つの画面をタブで持ちます。
  • src/screensディレクトリ
    • 前述した各画面ごとにファイルを作成し、コンポーネントを実装しています。
    • AFJ ExtensionのHooksからAgentの各種情報を取得し、React Native Paperのコンポーネントを使って画面を組み立てています。
  • src/EventListeners.ts: 前編で登場したEvent Listener群を持ちます。

Holder Controllerの実装

以下の順でコードを記載します。細かい点はコード上にコメントで記述しています。
なおEventListeners.tsのコードは前編と全く同じであるため、記載は割愛します。

  1. App.tsx
  2. navigatorsディレクトリ
    1. index.tsx
    2. BottomTab.tsx
    3. types.ts
  3. screensディレクトリ
    1. Connection.tsx
    2. Credentials.tsx
    3. ProofPresentHistory.tsx

1. App.tsx

import React, {useState, useEffect} from 'react';
import {Provider as PaperProvider} from 'react-native-paper';
import {
  InitConfig,
  Agent,
  WsOutboundTransport,
  HttpOutboundTransport,
  ConsoleLogger,
  LogLevel,
} from '@aries-framework/core';
import {agentDependencies} from '@aries-framework/react-native';
import AgentProvider from '@aries-framework/react-hooks';
import RootNavigator from './src/navigators';
import {
  registerConnectionListener,
  registerMediationListener,
  registerVCIssuingListener,
  registerProofPresentListener,
  registerRevocNotificationListener,
} from './src/EventListeners';

const App = () => {
  const [agent, setAgent] = useState<Agent | undefined>(undefined);

  const initializeAgent = async (): Promise<Agent> => {
    const genesisTransactionsResponse = await fetch(
      'http://<EC2インスタンスのPublic IPアドレス>:9000/genesis',
    );
    const genesisTransactionsOfVon = await genesisTransactionsResponse.text();
    console.log(genesisTransactionsOfVon);

    const config: InitConfig = {
      label: 'rn-holder-agent',
      walletConfig: {
        id: 'rn-holder-wallet',
        // 中編で述べたWalletマスターキーのパラメーターになるパスフレーズ。
        // 今回も前編と同じようにハードコードした。
        key: 'testkey0000000000000000000000000',
      },
      connectToIndyLedgersOnStartup: true,
      indyLedgers: [
        {
          id: 'aws-von-net',
          isProduction: false,
          genesisTransactions: genesisTransactionsOfVon,
        },
      ],
      autoAcceptConnections: true,
      useLegacyDidSovPrefix: true,
      logger: new ConsoleLogger(LogLevel.test),
    };

    const holder = new Agent(config, agentDependencies);
    holder.registerOutboundTransport(new HttpOutboundTransport());
    holder.registerOutboundTransport(new WsOutboundTransport());
    await holder.initialize();

    // did:sov:Vtw2qgmuMVy3rk2ipw7VxtはLedgerに登録したIssuerのPublic DID
    console.log('Ledgerへのアクセス疎通確認');
    const issuerDID = await holder.ledger.getPublicDid(
      'did:sov:Vtw2qgmuMVy3rk2ipw7Vxt',
    );
    console.log(JSON.stringify(issuerDID));

    console.log('registering event listeners...');
    registerConnectionListener(holder);
    registerMediationListener(holder);
    registerVCIssuingListener(holder);
    registerProofPresentListener(holder);
    registerRevocNotificationListener(holder);

    setAgent(holder);
    return holder;
  };

  useEffect(() => {
    // 初期処理
    (async () => {
      const holder = await initializeAgent();

      // 事前にMediator ACA-PyのAdmin APIから取得したInvitationのハードコード。
      const invitationURLFromMediator =
        'http://<EC2インスタンスのPublic IPアドレス>:8040?c_i=eyJAdHlwZSI...omit';
      await holder.oob.receiveInvitationFromUrl(invitationURLFromMediator);
    })();
  }, []);

  return (
    <AgentProvider agent={agent}>
      <PaperProvider>
        <RootNavigator />
      </PaperProvider>
    </AgentProvider>
  );
};

export default App;

2. navigatorsディレクトリ

(1) index.tsx
import React from 'react';
import {NavigationContainer} from '@react-navigation/native';
import BottomTabNavigator from './BottomTab';

const RootNavigator = () => {
  return (
    <NavigationContainer>
      <BottomTabNavigator />
    </NavigationContainer>
  );
};

export default RootNavigator;
(2) BottomTab.tsx
import React from 'react';
import {createBottomTabNavigator} from '@react-navigation/bottom-tabs';
import {List} from 'react-native-paper';

import {ConnectionsScreen} from '../screens/Connections';
import {CredentialsScreen} from '../screens/Credentials';
import {ProofPresentHistoryScreen} from '../screens/ProofPresentHistory';
import {NavigatorParamList} from './types';

const BottomTabNavigator = () => {
  const BottomTab = createBottomTabNavigator<NavigatorParamList>();

  return (
    <BottomTab.Navigator
      screenOptions={route => ({
        tabBarIcon: () => {
          let iconName = null;

          if (route.route.name === 'Connections') {
            iconName = 'connection';
          } else if (route.route.name === 'Credentials') {
            iconName = 'id-card';
          } else if (route.route.name === 'ProofPresentHistory') {
            iconName = 'history';
          } else {
            iconName = 'question';
          }

          return <List.Icon icon={iconName} />;
        },
      })}>
      <BottomTab.Screen name="Connections" component={ConnectionsScreen} />
      <BottomTab.Screen name="Credentials" component={CredentialsScreen} />
      <BottomTab.Screen
        name="ProofPresentHistory"
        component={ProofPresentHistoryScreen}
      />
    </BottomTab.Navigator>
  );
};

export default BottomTabNavigator;
(3) types.ts
import type {BottomTabNavigationProp} from '@react-navigation/bottom-tabs';

export type NavigatorParamList = {
  Connections: undefined;
  Credentials: undefined;
  ProofPresentHistory: undefined;
};

export type TabNavigationProp = BottomTabNavigationProp<NavigatorParamList>;

3. screensディレクトリ

(1) Connections.tsx
import React from 'react';
import {StyleSheet, FlatList, SafeAreaView} from 'react-native';
import {List} from 'react-native-paper';
import {ConnectionRecord} from '@aries-framework/core';
import {useConnections} from '@aries-framework/react-hooks';

export const ConnectionsScreen = () => {
  const connections = useConnections().records;

  return (
    <SafeAreaView style={styles.container}>
      <FlatList<ConnectionRecord>
        data={connections}
        keyExtractor={item => `${item.createdAt}`}
        renderItem={({item}) => (
          <List.Item
            title={`${item.theirLabel} / ${item.theirDid}`}
            description={`ConnID: ${item.id}, State: ${item.state}, CreatedAt: ${item.createdAt}`}
            titleNumberOfLines={3}
            descriptionNumberOfLines={4}
            left={props => <List.Icon {...props} icon="connection" />}
          />
        )}
      />
    </SafeAreaView>
  );
};

const styles = StyleSheet.create({
  container: {
    marginLeft: 10,
    marginRight: 10,
  },
});
(2) Credentials.tsx
import React from 'react';
import {StyleSheet, FlatList, SafeAreaView} from 'react-native';
import {Card, Paragraph} from 'react-native-paper';
import {CredentialExchangeRecord, CredentialState} from '@aries-framework/core';
import {useCredentialByState} from '@aries-framework/react-hooks';

export const CredentialsScreen = () => {
  const credentialsIssued = useCredentialByState(CredentialState.Done);

  return (
    <SafeAreaView style={styles.container}>
      <FlatList<CredentialExchangeRecord>
        data={credentialsIssued}
        keyExtractor={item => `${item.createdAt}`}
        renderItem={({item}) => (
          <Card>
            <Card.Cover
              // 画像は適当
              source={{
                uri: 'https://tech.gmogshd.com/wp-content/uploads/2022/10/afj-id-card.jpg',
              }}
            />
            <Card.Content>
              <Paragraph>{`State: ${item.state} / ConnectionID: ${item.connectionId} / CreatedAt: ${item.createdAt}`}</Paragraph>
            </Card.Content>
          </Card>
        )}
      />
    </SafeAreaView>
  );
};

const styles = StyleSheet.create({
  container: {
    marginLeft: 10,
    marginRight: 10,
  },
});

備考として、credentialsIssued: CredentialExchangeRecord[]の中身を例示します。
画面上には出していませんが、発行されたClaimの名前と値やCred Def IDなどIndy上のTransactionの情報も取得可能です。

[
   {
      "_tags":{
         "credentialIds":[
            "e80b8646-f982-4cce-bf80-47b37bb571d4"
         ],
         "state":"done",
         "connectionId":"e89b4ac6-2413-4da9-a3d5-1ed44ec02d82",
         "indyCredentialRevocationId":"1",
         "threadId":"aa7a2f3d-fbcc-4c27-8a0d-400729e89b78",
         "indyRevocationRegistryId":"Vtw2qgmuMVy3rk2ipw7Vxt:4:Vtw2qgmuMVy3rk2ipw7Vxt:3:CL:9:1:CL_ACCUM:324bb785-e361-40ea-9a56-0c70ce0105b6"
      },
      "metadata":{
         "_internal/indyRequest":{
            "master_secret_blinding_data":{
               "v_prime":"27291210913640622679169652571822589802608170345529185155031194540557017446279362753614344612801014427446042251292018179065525947314823815805924788661586483461579977787995855949626038496580052965560900147297605120995819998496711744011553280038677609434174635563738205447586736248570894629258337688401702085280171927519207419744586025190589767433311988739250553731153088527485305947084391141851810896665267124681649708668684138606782073860803790146575708592404867345432828781287564225851931975402632513507205887969594828452157522876110543887480083568706584076741850632630821089115015478604703739875381057658198512792393570017901164366540892566",
               "vr_prime":"0C25978C19E41BA7F2AC9F73737F125F25CD34297623A70A35ECA74D940765DA"
            },
            "nonce":"727919968077444645934384",
            "master_secret_name":"rn-holder-wallet"
         },
         "_internal/indyCredential":{
            "credentialDefinitionId":"Vtw2qgmuMVy3rk2ipw7Vxt:3:CL:9:1",
            "schemaId":"Vtw2qgmuMVy3rk2ipw7Vxt:2:employee:1.0",
            "indyCredentialRevocationId":"1",
            "indyRevocationRegistryId":"Vtw2qgmuMVy3rk2ipw7Vxt:4:Vtw2qgmuMVy3rk2ipw7Vxt:3:CL:9:1:CL_ACCUM:324bb785-e361-40ea-9a56-0c70ce0105b6"
         }
      },
      "credentials":[
         {
            "credentialRecordType":"indy",
            "credentialRecordId":"e80b8646-f982-4cce-bf80-47b37bb571d4"
         }
      ],
      "id":"3e6d65f5-a664-415d-9037-5d354b8d860d",
      "createdAt":"2022-10-03T02:27:15.415Z",
      "state":"done",
      "connectionId":"e89b4ac6-2413-4da9-a3d5-1ed44ec02d82",
      "threadId":"aa7a2f3d-fbcc-4c27-8a0d-400729e89b78",
      "protocolVersion":"v1",
      "credentialAttributes":[
         {
            "mime-type":"text/plain",
            "name":"name",
            "value":"Alice"
         },
         {
            "mime-type":"text/plain",
            "name":"department",
            "value":"R&D"
         },
         {
            "mime-type":"text/plain",
            "name":"employee_id",
            "value":"12345"
         }
      ]
   }
]
(3) ProofPresentHistory.tsx
import React from 'react';
import {StyleSheet, FlatList, SafeAreaView} from 'react-native';
import {List} from 'react-native-paper';
import {ProofRecord} from '@aries-framework/core';
import {useProofs} from '@aries-framework/react-hooks';

export const ProofPresentHistoryScreen = () => {
  const proofRecords = useProofs().records;

  return (
    <SafeAreaView style={styles.container}>
      <FlatList<ProofRecord>
        data={proofRecords}
        keyExtractor={item => `${item.createdAt}`}
        renderItem={({item}) => (
          <List.Item
            title={`${item.requestMessage?.comment}`}
            description={`ConnectionID: ${item.connectionId}, State: ${item.state}, ProofPresentedAt: ${item.createdAt}`}
            titleNumberOfLines={3}
            descriptionNumberOfLines={4}
            left={props => <List.Icon {...props} icon="check" />}
          />
        )}
      />
    </SafeAreaView>
  );
};

const styles = StyleSheet.create({
  container: {
    marginLeft: 10,
    marginRight: 10,
  },
});

動作確認

確認事項

以下、大まかな確認事項です。No.1と2は前編と同様です。
No.3は前編にて上手くいかず課題になっていた点です。

  1. 未失効のVCから成るProofの有効性の検証に成功すること(有効なProofであることの確認)
  2. 失効済みのVCから成るProofの有効性の検証に失敗すること(無効なProofであることの確認)
  3. MediatorからHolderへのDIDCommメッセージ送信はWebSocketで行われること。

全体的な処理の流れ

コロンの左部分は右部分の処理のトリガーとなる主体を表しています。

  1. 筆者: AWS上のコンテナ群を起動する。
    • Von Networkについては前述しましたが、ACA-PyとIndy Tail ServerについてもスマートフォンであるHolderからアクセスできるよう、構成設定においてEndpointにEC2 InstanceのPublic IPアドレスを設定します。
  2. 筆者: スマートフォン上でHolder Agent(スマホアプリ)を起動する。
  3. Holder Agent: 初期処理の中で、ハードコードしたMediatorからのInvitationを読み込み、Mediatorに対しConnectionとMediationを確立する。またIssuer、Verifierそれぞれに対するInvitaitonをロギングする。
    • 前編で述べた通りMediator ACA-Pyの起動パラメーターのEndpointはHTTPとWebSocketの2種類を設定して起動しています。ACA-Py Admin APIでのInvitation作成では、その中のEndpointをHTTPのそれにします。まずHTTPでのConnectionを作ります。Connection生成完了後、Event Listener(前編参照)からWebSocketによるMediationを開始する流れにしています。
    • 以下、上記App.tsx内にハードコードしたInvitationです。(Invitation URLのクエリパラメーター部分のデコードです。)
{
   "@type":"did:sov:BzCbsNYhMrjHiqZDTUASHg;spec/connections/1.0/invitation",
   "@id":"ad8158fb-d80d-4c3e-bf13-6c2e2bae6408",
   "recipientKeys":[
      "2YfdA2fLVtEGhqw9cBjXZM4dRxmFPZyv4PTSTEk395a6"
   ],
   "serviceEndpoint":"http://<EC2のPublic IPアドレス>:8040",
   "label":"mediator"
}

ここからのVC発行、Proof検証、VC失効とProof再検証の流れは前編に記載した内容と同じであるため割愛します。

備考: Transports Return Route

ここでは、Connectionの生成部分、及びMediation要求/許可の部分(つまりWebSocket開始までの間)において、Endpointを持たないMobile AgentであるHolderがどうそれを処理するのか?、つまりどうMediatorからHolder方向へDIDCommメッセージを送信するのか?について述べます。

結論としては、Holder側でAries RFC 0092: Transports Return Route仕様の”all”を設定し、MediatorにHTTPリクエストを送信します。P2Pで非同期でメッセージを送り合うアーキテクチャの中、通常のDIDCommではHTTPレスポンスのボディには何も情報が入っていませんが、この設定により、MediatorはHTTPレスポンスにDIDCommメッセージを詰めて返却します。

Holder側はConnection Requestに対するConnection Response、またMediation Requestに対するMediation Grant(またはDeny)をHTTPレスポンスとして受け取れるため、シーケンスを進められるわけです。

また、これが可能なのはConnection生成とMediation開始処理において、Mediatorの構成設定において、Connectionプロトコルを自動で進めるautoパラメータと、誰にでもMediationを許可するopen-mediationパラメータを付与しているためだと推測します。

AFJの具体的にコードで示すと、v0.2.2/packages/core/src/agent/MessageSender.ts#L231でInbound Endpointが無い場合にReturn Route:allを設定していることがわかります。

結果と課題

上記の3つの事項全てについて、エミュレーターでアプリ(Holder Agent)を起動した際は上手くいくことを確認できました。しかし、筆者のスマートフォン(Pixel 4/Android OS 13)では起動直後にクラッシュしてしまう現象が起きました。ここは課題が残りました。

現在原因調査中であり、解決次第この記事を更新したいと思います。

2022/10/17追記

調査の結果、原因は環境構築でプロジェクトにAndroid用のIndy SDKを配備する際に、筆者のスマートフォンのCPUアーキテクチャ(arm64-v8a)に対応するShared Library file(.soファイル)を置いていなかったことでした。(エミューレーター用のx86_64のファイルしか置いていなかった。)

対応後、問題が解決されたことを確認できました。

おわりに

後編であるこの記事では、Edge型の簡易なHolder Agentを作りVCモデルを一通り回すことを試しました。

AFJを基盤にしたHolder Agentのプロダクトの事例として、世界では少しずつ動きが出てきています。

(著者の記事でも度々登場するvon-networkやindy-tail-serverの開発元で、Indy/Ariesをリードしてきた)カナダ・ブリティッシュコロンビア州政府が開発したBC Walletが既にApp Store/Google Playにリリースされています。ただし、2022年9月現在、ほぼ全てのユーザーに対しまだ使用用途が用意されていないようです。

また同じくカナダでSSIプラットフォームを提供するスタートアップ、Nothern Blockも近々AFJ基盤のHolder Agentをリリースする予定だという情報があります。

どなたかのお役に立てたならば幸いです。