LoginSignup
2
0

credo-tsでOID4VCを体験する(その1)

Last updated at Posted at 2024-02-18

はじめに

 さまざまなところでwallet開発が行われているが、その中でもLinux FoundationがすすめるOpen Wallet Foundation(OWF)はさまざまなプロトコルが合流しインターオペラビリティが図られるので魅力的だ。この団体は2023/2に設立され、その開発方向性を示すホワイトペーパ:「今なぜ世界がオープンソースのデジタルウォレットを必要としているか」を出している。

どのデバイス、どのオペレーティング システム、どのアプリやサービス、どの通貨にも対応し、誰でも使用できるポータブルで安全なウォレットの開発を支援するために設立

 そして2023/12に、Hyperledger配下だったAFJ(Aries Framework JavaScript)がOWFに合流し、credo projectが発足した。AFJがもつanonCreds, DIDComm, OID4VC, JSON-LD,..がOWFの中に入り、いままでOWFアーキだけしか見えなかったのが、動くものとして見えるようになってきている。(個人的には、1つの鍵でOID4VC, DIDCommそれぞれがもつユースケースをさばけるようなid walletを試してみたい..。)

 今日はこのcredo-tsを用いてwalletを作り、sphereonのOID4VCデモサイトにつなげてVCが取得できるところまでを試してみたいと思う。 試す前に少しcredo-tsのソフトウェア構成を覗いてみようと思う。

credo-tsの中身

credo-tsを用いたdemoプログラムは、credo-ts/demo-openid/ にまとめられている。動きだけを確認するのであれば、readmeに書かれているように、issuer/holder/verifierをそれぞれ起動して確認することができる。ここでは、sphereon社が作ったOID4VC issuerデモサイトにつなげて、credo-tsの動きを見ていきたいと思う。

1. baseAgent

ベースになっているクラスである。Agentがもつ鍵生成と、Agentが話すプロトコルに応じたmodule群を読み込む作業を担ってくれる。

baseAgent.ts
import type { InitConfig, KeyDidCreateOptions, ModulesMap, VerificationMethod } from '@credo-ts/core'
... <snip> ...

export class BaseAgent<AgentModules extends ModulesMap> {
... <snip> ...

 // 読み込んだ引数に従って、Agentに設定する名前や、読み込むmodulesを設定
  public constructor({ port, name, modules }) {
    this.app = express()

    const config = {
      label: name,
      walletConfig: { id: name, key: name },
    } satisfies InitConfig

    this.agent = new Agent({ config, dependencies: agentDependencies, modules })
    ...<snip>...
  }
  // constructotで設定された値どおりにagentを初期化。引数にsecretPrivateKeyをもち、その値でdid keyを生成し、agentに設定する。
  public async initializeAgent(secretPrivateKey: string) {
    await this.agent.initialize()

    const didCreateResult = await this.agent.dids.create<KeyDidCreateOptions>({
      method: 'key',
      options: { keyType: KeyType.Ed25519 },
      secret: { privateKey: TypedArrayEncoder.fromString(secretPrivateKey) },
    })

    this.did = didCreateResult.didState.did as string
    this.didKey = DidKey.fromDid(this.did)
    this.kid = `${this.did}#${this.didKey.key.fingerprint}`

    const verificationMethod = didCreateResult.didState.didDocument?.dereferenceKey(this.kid, ['authentication'])
    if (!verificationMethod) throw new Error('No verification method found')
    this.verificationMethod = verificationMethod
    ...<snip>...    
  }
}

2. Holder

 Holderは、さきほどのBAeAgentを拡張してOID4VC仕様でやりとりができるよういくつかの関数が+αされている。付加された関数は@credo-ts/openid4vcに定義されているもので、主にofferを分析する関数と、token/credential取得する関数である。

Holder.ts
import { AskarModule } from '@credo-ts/askar'
import { BaseAgent } from './BaseAgent'
import { OpenId4VcHolderModule } from '@credo-ts/openid4vc'
import { ariesAskar } from '@hyperledger/aries-askar-nodejs'
...<snip>...

// agentに読みこむモジュール
function getOpenIdHolderModules() {
  return {
    askar: new AskarModule({ ariesAskar }),
    openId4VcHolder: new OpenId4VcHolderModule(),
  } as const
}

// baseAgentを拡張して、Holderを定義
export class Holder extends BaseAgent<ReturnType<typeof getOpenIdHolderModules>> {
  public constructor(port: number, name: string) {
    super({ port, name, modules: getOpenIdHolderModules() })
  }

  // BaseAgentを呼び出して、agentを初期化。
  public static async build(): Promise<Holder> {
    const holder = new Holder(3000, 'OpenId4VcHolder ' + Math.random().toString())
    await holder.initializeAgent('96213c3d7fc8d4d6754c7a0fd969598e')
    return holder
  }

 // OID4VCI仕様で決められるofferを処理する関数
  public async resolveCredentialOffer(credentialOffer: string) {
    return await this.agent.modules.openId4VcHolder.resolveCredentialOffer(credentialOffer)
  }

  // OID4VC仕様に従って、issuerからtokenをとり、その後credentialを取得する関数
  public async requestAndStoreCredentials(
    resolvedCredentialOffer: OpenId4VciResolvedCredentialOffer,
    credentialsToRequest: string[]
  ) {
    const credentials = await this.agent.modules.openId4VcHolder.acceptCredentialOfferUsingPreAuthorizedCode(
      resolvedCredentialOffer,
      {
        credentialsToRequest,
        // TODO: add jwk support for holder binding
        credentialBindingResolver: async () => ({
          method: 'did',
          didUrl: this.verificationMethod.id,
        }),
      }
    )

    const storedCredentials = await Promise.all(
      credentials.map((credential) => {
        if (credential instanceof W3cJwtVerifiableCredential || credential instanceof W3cJsonLdVerifiableCredential) {
          return this.agent.w3cCredentials.storeCredential({ credential })
        } else {
          return this.agent.sdJwtVc.store(credential.compact)
        }
      })
    )
    return storedCredentials
  }
... <snip> ...
}

3. wallet

さきほど説明したHolderを使って、OID4VC仕様のQRコードを処理するには、以下のような順番で関数を呼んでいけばOKである。

// offerを処理。token/credential issuerと通信するためのendopointなどを算出(※)
const resolvedCredentialOffer = await wallet.holder.resolveCredentialOffer(initiationURI);

// 処理されたresolvedCredentialOfferを用いて、credentialsを取得
const credentials = await wallet.holder.requestAndStoreCredentials(
   resolvedCredentialOffer,
   resolvedCredentialOffer.offeredCredentials.map((o) => o.id)
)

// 取得されたcredentialを表示。
if (credentials[0].type === 'W3cCredentialRecord') {
  console.log(greenText(`W3cCredentialRecord with claim format ${credentials[0].credential.claimFormat}`, true))
  console.log(JSON.stringify(credentials[0].credential.jsonCredential, null, 2))
} 

(※)credo-tsのresolveCredentialOffer関数は、offerに含まれる"credentials":["OpenBadgeCredential"]が、issuer metadataの中のcredentials_supported/idの中に含まれているかどうかを調べる。なのでcredentials_supported[{..id:"OpenBadgeCredentialJwt",...},{}]と不一致だとエラーになる。

体験してみよう

1. credo-tsで作ったwalletコードを配置

 server.ts, Holder.ts, BaseAgent.ts, OutputClass.ts、package.json, index.html, qr-scanner.min.js, qr-scanner-worker.min.jsを以下のフォルダ構成のように配置する.

├── server.ts
├── Holder.ts  (*1)
├── BaseAgent.ts (*1)
├── OutputClass.ts (*1)
├── package.json
└── views/
   ├── index.html
   ├── qr-scanner.min.js (*2)
   ├── qr-scanner-worker.min.js (*2)

(*1)は、https://github.com/openwallet-foundation/credo-ts/tree/main/demo-openid/src からdownloadする。
(*2)は、https://github.com/nimiq/qr-scanner からdownloadする。

server.tsのコードを見る
server.ts
import { Holder } from './Holder'
import { Title, greenText, redText } from './OutputClass'
import { textSync } from 'figlet'

import  express from 'express';
import bodyParser from 'body-parser';
import * as path from "path";

//************
const port=8800;
let wallet:Agent
//*************

const app: express.Express = express();
app.use('/', express.static(path.join(process.cwd(), 'views')));
app.use(bodyParser.json());


app.listen(port, async () => {
       console.log(textSync('Holder', { horizontalLayout: 'full' }))
       wallet = await Agent.build()
       console.log(`\n web page start: listening on port ${port}`);
});


app.post('/.*', async function(req:express.Request,res:express.Response){
	console.log("\n====POST====");
	console.log("cmd:"+JSON.stringify(req.params));
	console.log("query:"+JSON.stringify(req.query));
	console.log("headers:"+JSON.stringify(req.headers));
	console.log(req.body);
	console.log("====POST====");

  let ret="";
  let cmd = req.params[0].split('/');
  if (cmd[0] =="issuer_init"){
      console.log("init")
      let cred =  await getCredential(req.body.qr)
      res.send(cred);
  }
})

async function getCredential(_initiationURI:string){
      let initiationURI = _initiationURI.replace("OpenBadgeCredential", "OpenBadgeCredentialJwt")

      const resolvedCredentialOffer = await wallet.holder.resolveCredentialOffer(initiationURI);
      console.log(greenText(`Received credential offer for the following credentials.`))
      console.log(greenText(resolvedCredentialOffer.offeredCredentials.map((credential) => credential.id).join('\n')))

      const credentials = await wallet.holder.requestAndStoreCredentials(
         resolvedCredentialOffer,
        resolvedCredentialOffer.offeredCredentials.map((o) => o.id)
      )
      console.log(credentials)
      let decoded={}
     if (credentials[0].type === 'W3cCredentialRecord') {
        console.log(greenText(`W3cCredentialRecord with claim format ${credentials[0].credential.claimFormat}`, true))
        console.log(JSON.stringify(credentials[0].credential.jsonCredential, null, 2))
        decoded = credentials[0].credential.jsonCredential;
    } 

    return {result:true, detail:credentials,decode:decoded};
}

export class Agent {
  public holder: Holder

  public constructor(holder: Holder) {
    this.holder = holder
  }

  public static async build(): Promise<Agent> {
    const holder = await Holder.build()
    return new Agent(holder)
  }
}

package.jsonのコードを見る
package.json
{
  "name": "agent-credo-ts",
  "version": "1.0.0",
  "scripts": {
    "start": "ts-node server.ts",
    "stop": "rm -rf ./node_modules ./yarn.lock && yarn"
  },
  "dependencies": {
    "@hyperledger/anoncreds-nodejs": "^0.2.0",
    "@hyperledger/aries-askar-nodejs": "^0.2.0",
    "@hyperledger/indy-vdr-nodejs": "^0.2.0",
    "body-parser": "^1.20.2",
    "express": "^4.18.1",
    "inquirer": "^8.2.5"
  },
  "devDependencies": {
    "@credo-ts/askar": "*",
    "@credo-ts/core": "*",
    "@credo-ts/node": "*",
    "@credo-ts/openid4vc": "*",
    "@types/express": "^4.17.13",
    "@types/figlet": "^1.5.4",
    "@types/inquirer": "^8.2.6",
    "figlet": "^1.5.2",
    "ts-node": "^10.4.0"
  }
}

index.htmlのコードを見る
index.html
<!DOCTYPE html>
<html lang='en'>

<head><meta charset='utf-8'></head>
<body>
  <div align=center>
    <div id="title">[ID Wallet]</div><br>
    <button id="scanQR">scan QR code</button>, <button id="stop-button">Stop</button>
    <p><div id=msg></div>
    <p><div id=qr></div>
    <div id="video-container">
      <video id="qr-video"></video>
    </div>
  </div>
<b>Detected QR code: </b>
<span id="cam-qr-result">None</span>
<div id="credresult"></div>
<br>
<hr></body>
<script type="module">
    import QrScanner from "./qr-scanner.min.js";

    let credentials =[]
    const video = document.getElementById('qr-video');
    const videoContainer = document.getElementById('video-container');
    const camQrResult = document.getElementById('cam-qr-result');
    const credResult = document.getElementById('credresult');

    function setResult(label, result) {
        console.log(result.data);
        scanner.stop();
        label.textContent = result.data;
        label.style.color = 'teal';
        _init(result.data)
    }

    const scanner = new QrScanner(video, result => setResult(camQrResult, result), {
        onDecodeError: error => {
            camQrResult.textContent = error;
            camQrResult.style.color = 'inherit';
        },
        highlightScanRegion: true,
        highlightCodeOutline: true,
    });

    document.getElementById('scanQR').addEventListener('click', () => {
        scanner.start();
    });


    function _init(_qrcode) {
      let setting={
        qr:_qrcode
      }
      sendData("./.issuer_init", JSON.stringify(setting), 'application/json', 
        function(data){
          data = JSON.parse(data)
          if (data.result){
            credentials.push({detail:data.detail, decode:data.decode})
            credResult.innerHTML = "<pre>"+JSON.stringify(credentials[credentials.length-1].decode, null,"\t")+"</pre>";
          }else{
            credResult.innerHTML = "<br>--> failed<br>";
          }
        }
      );
    }

    // for debugging
    window.scanner = scanner;

    document.getElementById('stop-button').addEventListener('click', () => {
        scanner.stop();
    });


</script>
<script type="text/javascript">
  window.addEventListener('load',function() {  
  });

  function getData(url, type, cb){
    let xhr = new XMLHttpRequest();
    xhr.onreadystatechange = function(){
      switch(xhr.readyState){
        case 4:
          if (xhr.status == 200||xhr.status ==304){
            cb(xhr.response)
          }
      }
    };
    xhr.open("GET", url, false);
    xhr.setRequestHeader('Content-Type', type);
    xhr.send('');
  }

  function sendData(url, data, type, cb){
    let xhr = new XMLHttpRequest();
    xhr.onreadystatechange = function(){
      switch(xhr.readyState){
        case 4:
          if (xhr.status == 200||xhr.status ==304){
            console.log("send success");
            cb(xhr.response)
          }
      }
    };
    xhr.open("POST", url, false);
    xhr.setRequestHeader('Content-Type', type);
    xhr.send(data);
  }
  </script>
</html>

2. 関連モジュールをインストールする

(注) 動作確認した環境は、nodejs(v18.18.0)/WSL(Ubuntu-20.04.6)/windows 10

> yarn
...<snip>...
? Please choose a version of "@credo-ts/askar" from this list: (Use arrow keys)
❯ 0.5.0-alpha.138
  0.5.0-alpha.137
  0.5.0-alpha.136
  0.5.0-alpha.135
  0.5.0-alpha.134
  0.5.0-alpha.133
  0.5.0-alpha.132
  0.5.0-alpha.131
  0.5.0-alpha.130

インストール途中、上記のようにversion選択画面が何度かでてくる。動作確認は最新版の0.5.0-alpha.138で行っている

3. wallet に割り当てるドメイン名を取得

> ngrok http 8800
Forwarding       https://fe86-153-214-37-xxx.ngrok-free.app -> http://localhost:8800 

4. credo-tsで作ったwallet を起動

環境によって起動に時間がかかる。筆者の環境だと5分程度

> ts-node server.ts
  _   _           _       _
 | | | |   ___   | |   __| |   ___   _ __
 | |_| |  / _ \  | |  / _` |  / _ \ | '__|
 |  _  | | (_) | | | | (_| | |  __/ | |
 |_| |_|  \___/  |_|  \__,_|  \___| |_|


Agent OpenId4VcHolder 0.7639780978890356 created!


 web page start: listening on port 8800

5. スマホブラウザでhttps://fe86-153-214-37-xxx.ngrok-free.app/にアクセス

  1. sphereonのOID4VCのデモサイトにアクセスし、QRコードを表示fig1.png

  2. スマホブラウザの「scan QR code」ボタンを押して、デモサイトのQRコードを読み取るfig2.png

  3. 読み取ったQRコードは、credo-tsで作ったwalletに送られ、処理される。

====POST====
cmd:{"0":"issuer_init" }
query:{}
headers:{"host":"fe86-153-214-37-xxx.ngrok-free","user-agent":"Mozilla/5.0 (iPhone; CPU iPhone OS 17_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/122.0.6261.51 Mobile/15E148 Safari/604.1","content-length":"372","accept":"*/*","accept-encoding":"gzip, deflate, br","accept-language":"ja","content-type":"application/json","origin":"https://fe86-153-214-37-xxx.ngrok-free","referer":"https://fe86-153-214-37-xxx.ngrok-free/","x-forwarded-for":"xxx.xx.xx.xxx","x-forwarded-host":"fe86-153-214-37-xxx.ngrok-free","x-forwarded-proto":"https"}
{
  qr: 'openid-credential-offer://?credential_offer=%7B%22grants%22%3A%7B%22urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Apre-authorized_code%22%3A%7B%22pre-authorized_code%22%3A%22iD4dqVcNaCerMHpSeFF9AM%22%2C%22user_pin_required%22%3Afalse%7D%7D%2C%22credentials%22%3A%5B%22OpenBadgeCredential%22%5D%2C%22credential_issuer%22%3A%22https%3A%2F%2Fssi.sphereon.com%2Fpf3%22%7D'
}
====POST====
init
Received credential offer for the following credentials.
OpenBadgeCredentialJwt
[
  W3cCredentialRecord {
    _tags: { expandedTypes: [] },
    type: 'W3cCredentialRecord',
    metadata: Metadata { data: {} },
    id: '8e513c01-b6dc-474f-9048-649457f54240',
    createdAt: 2024-02-18T03:09:31.343Z,
    credential: W3cJwtVerifiableCredential {
      jwt: [Jwt],
      _credential: [W3cCredential]
    },
    updatedAt: 2024-02-18T03:09:31.343Z
  }
]
W3cCredentialRecord with claim format jwt_vc

最終的に、OID4VCプロトコルで取得されたVC(Verifiable Credential)はブラウザに送り返され、下記のようにスマホ上でみることができる。
fig3.png

参考記事

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0