はじめに
さまざまなところで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群を読み込む作業を担ってくれる。
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取得する関数である。
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のコードを見る
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のコードを見る
{
"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のコードを見る
<!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/
にアクセス
-
sphereonのOID4VCのデモサイトにアクセスし、QRコードを表示
-
読み取った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)はブラウザに送り返され、下記のようにスマホ上でみることができる。