[2024/02/10] Woodgrove Verified ID demo siteサイトで顔写真入りのVC発行がerrorになっているので、体験サンプルにVC発行機能を追加
はじめに
下記記事にあるように、スマホに入っているAthenticator/id walletを触って遊んでいる。中でもMS authenticatorは、独自機能が試験的に盛り込まれていて触っていて面白い。
そのMS authenticatorにfaceCheck機能が入っているという話を聞いたので、さっそく中身を見ていきたいと思う。
faceCheck機能はVerifiable Credential(vc)の中に顔写真を埋め込み、verifierサイトにログインするときに、そのVCの持ち主かどうかをVCに埋め込まれた顔写真で確認するものである。
チケットの転売などがVCになってきてスマホに紐付けられるようになっても、転売ヤーはスマホごと売り払えばいいという話になってしまうが、VC型のチケット発行時に購入者の写真をVCに埋め込んで発行すれば、そういう心配はいらないという感じなんだろう。そういうタイプのユースケースには響きそうな機能なんだなと感じる。
faceCheckの流れ
1. スマホにVCを発行してもらう
Woodgrove Verified ID demo siteにアクセスして、「Student Card」を発行してもらう。発行画面は下記のような感じである。Optionであるが、「choose File」で手持ちの顔写真(*.webp)を選択すると下記のような登録画面になる。
このときスマホに発行されたVCを見てみると下記のようになっている。claimの中にphotoという項目が増え、そこにはwebpフォーマットの接頭語が見えるので、登録時にuploadした顔写真のraw dataをそのまま放り込んでいる感じである。
{
"vc": {
"@context": [
"https://www.w3.org/2018/credentials/v1"
],
"type": [
"VerifiableCredential",
"StudentCard"
],
"credentialSubject": {
"id": "4651575414",
"firstName": "test",
"lastName": "Example",
"photo": "/9j/4AAQSkZJRgABAQAAAQABAAD/4gHYSUNDX1BST0ZJTEUAAQEAAAHIAAAAAAQwAABtbnRyUkdCIFhZWiAH4AABAAEAAAAAAABhY3NwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAA9tYAAQAAAADTLQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAlkZXNjAAAA8AAAACRyWFlaAAABFAAAABRnWFlaAAABKAA.<snip>.Hemwsf2cbsqSd33/lb2z5UUTb2f/Z"
},
"credentialStatus": {
"id": "urn:uuid:1b23a846-040b-4508-8b75-cdb5bf4659a1?bit-index=116",
"type": "RevocationList2021Status",
"statusListIndex": 116,
"statusListCredential": "did:web:verifiedid.entra.microsoft.com:1f9ef086-c2be-462a-9f4e-88f0a4d01691:03640502-457c-f278-996a-ffd9cb83c308?service=IdentityHub&queries=W3sibWV0aG9kIjoiQ29sbGVjdGlvbnNRdWVyeSIsInNjaGVtYSI6Imh0dHBzOi8vdzNpZC5vcmcvdmMtc3RhdHVzLWxpc3QtMjAyMS92MSIsIm9iamVjdElkIjoiMWIyM2E4NDYtMDQwYi00NTA4LThiNzUtY2RiNWJmNDY1OWExIn1d"
}
},
"jti": "urn:pic:b4c8377a4be44673b5ee17458c1e25cd",
"iss": "did:web:verifiedid.entra.microsoft.com:1f9ef086-c2be-462a-9f4e-88f0a4d01691:03640502-457c-f278-996a-ffd9cb83c308",
"sub": "did:ion:EiAn6Yue05YAACxU8mHts_83cm9AHuYDVO1ljEegAYAwgw:eyJkZWx0YSI6eyJwYXRjaGVzIjpbeyJhY3Rpb24iOiJyZXBsYWNlIiwiZG9jdW1lbnQiOnsicHVibGljS2V5cyI6W3siaWQiOiJzaWduX0c4QnpsbjlNSlAiLCJwdWJsaWNLZXlKd2siOnsiYWxnIjoiRVMyNTZLIiwiY3J2Ijoic2VjcDI1NmsxIiwia2V5X29wcyI6WyJ2ZXJpZnkiXSwia2lkIjoic2lnbl9HOEJ6bG45TUpQIiwia3R5IjoiRUMiLCJ1c2UiOiJzaWciLCJ4IjoiTzh4T2oxY09VVGpadzlUTkh6NzE4OGFfOWNQWTMtQkxJZElXa0xGY0R1MCIsInkiOiI3Wko0U2FBNVJXN3ZNM2VrNTNtMGN1NEtxUmUyOUtrQzJDaWNxSkpnTy1ZIn0sInB1cnBvc2VzIjpbImF1dGhlbnRpY2F0aW9uIl0sInR5cGUiOiJFY2RzYVNlY3AyNTZrMVZlcmlmaWNhdGlvbktleTIwMTkifV0sInNlcnZpY2VzIjpbXX19XSwidXBkYXRlQ29tbWl0bWVudCI6IkVpQU55VzV3SElmRjRBb0RmVUNoQ3Y0MkM5NFhKTkNxX0F4bWlkWDZYc2VjLVEifSwic3VmZml4RGF0YSI6eyJkZWx0YUhhc2giOiJFaUMxYU1wYmRQQmVoelBtVC1hRlpmZ2FNelZsOF9BZnhEczFqMlh1V2diUmJBIiwicmVjb3ZlcnlDb21taXRtZW50IjoiRWlBT2g0ZXRKUWZLalljV3RsWlBuT1A4aUg1cmhUSjNUeUhJbkEyNGVfUTA4USJ9fQ",
"iat": 1707020794,
"exp": 1712275199
}
デモサイトから発行されたVCをMS Authenticatorアプリで見てみると、顔写真が入っているのがわかる。この辺りはmanifestでclaim photoの内容をdisplayするように記載してあるからなんだろうな..。
2. verifierでvcを検証する(+faceCheck)
2.1 invite用QRコードを提示
以下のようなQRコードをMS authenticatorでスキャンする。読み取れる内容は、URL(openid-vc://?request_uri=https://5070-153-132-214-182.ngrok-free.app/.presentationRequests/ebab5252be665477
)である。
2.2 faceCheck
スキャンすると、MS authenticatorアプリは、redirect_uriに示されたhttps://5070-153-132-214-182.ngrok-free.app/.presentationRequests/ebab5252be665477
からrequestを読み出し、そのrequestに書かれた内容に従って、以下のような画面遷移を起こす。
MS Authenticatorが読み取ったrequestは、以下のような内容である。このrequestの中でfaceCheck特有の値はinput_descriptorsに書かれている{"uri": "LivenessCheck"}
である。これが指定されることでMS authenticatorは顔の読み取りを行うようだ。
{
"alg": "ES256K",
"kid": "did:web:5070-153-132-214-182.ngrok-free.app#owner",
"typ": "JWT"
}.{
"iat": 1707042076,
"exp": 1707045676,
"jti": "c430775d-d6f1-4f27-ba07-b565fa891921",
"response_type": "id_token",
"response_mode": "post",
"scope": "openid",
"nonce": "eq+eC75SXMtcarpPAP60+Q==",
"client_id": "did:web:5070-153-132-214-182.ngrok-free.app",
"redirect_uri": "https://5070-153-132-214-182.ngrok-free.app/.presentationRequests/64819363fd253117",
"state": "123456789",
"registration": {
"client_name": "839 home",
"subject_syntax_types_supported": ["did:ion","did:key","did:jwk"],
"vp_formats": {
"jwt_vp": { "alg": ["ES256K","EdDSA"] },
"jwt_vc": { "alg": ["ES256K","EdDSA"] }
}
},
"claims": {
"vp_token": {
"presentation_definition": {
"id": "b3a5f8a8-c7f7-427b-ade0-3611f84f6314",
"input_descriptors": [
{
"id": "Any student card",
"purpose": "So we can see accept any studentCard scheme",
"schema": [{"uri": "StudentCard"}]
},
{
"id": "a2374f30-fa89-40fe-9e0b-47dc3fd736a0",
"name": "LivenessCheck",
"schema": [{"uri": "LivenessCheck"}]
}
]
}
}
},
"iss": "did:web:5070-153-132-214-182.ngrok-free.app#owner"
}.[signature]
2.3 verifierにMS Authenticatorからpostされる内容
スマホは顔の読み取りを終えると、verifierに結果を返してくれる。返し先はrequestに書かれた"redirect_uri": "https://5070-153-132-214-182.ngrok-free.app/.presentationRequests/64819363fd253117
に"response_mode": "post"
で書き込みが行われる。書き込まれるjsonは下記内容である。
{
id_token:"eyJ0eXAiOiJ....UzI1NksiLCJraWQiOiJkaWQ6aW9uOkVpQW42WXVlMDVZQUFDeFU4bUh0c....ZDRmYWIxLTU0ODktNGRlZi1hMDI0LTkwY2VkODM3MDZkZCIsImlkIjoiM0NDQjg1NDUtQkNGOS00QzFFLThGRTAtQjg0REIzNjE5MDU4In19fQ.1oACywpUGgkpLKQNGXCRLsiPVFlirBfs5MnihQSa401WpB24buqXCOgNz6NlSBa26fXjOZVibJRV_OL6RiWb6Q",
vp_token: "eyJraWQiOiJ.....BvMFNXcHZhVlI2YURSVU1tOTRXVEE1VmxaSGNHRmtlbXhWVkd0b05rNTZSVFJQUjBabVQxZE9VVmRVVFhSUmEzaEtXa1ZzV0dFd2VFZFpNRkl4VFVOSmMwbHVhMmxQYVVrelYydHZNRlV5UmtKT1Z",
state: "123456789"
}
この中のvp_tokenの中身を詳しく見ていくと、以下のように2つのVC1, VC2が入っていることがわかる。VC1は上記で紹介したClaimにphotoが入ったstudentCard VCである。
{
"kid":"did:ion:EiAn6Yue05YAACxU8mHts_83cm9AHuYDVO1ljEegAYAwgw:eyJkZWx0YSI...VfUTA4USJ9fQ#sign_G8Bzln9MJP",
"typ":"JWT",
"alg":"ES256K"
},{
"jti":"C6A5DDA5-CC7F-4269-9123-57D70742479A",
"iat":1707043255,
"iss":"did:ion:EiAn6Yue05YAACxU8mHts_83cm9AHuYDVO1ljEegAYAwgw:eyJkZWx0YSI...VfUTA4USJ9fQ",
"nbf":1707043255,
"aud":"did:web:5070-153-132-214-182.ngrok-free.app",
"exp":1707046255,
"nonce":"MN1mt9y9Oh672kB1sYN5qw==",
"vp":{
"verifiableCredential":[
"eyJhbGciOiJFUzI1NksiLCJraWQiOiJkaWQ6d2....", // VC1
"eyJraWQiOiJkaWQ6aW9uOkVpQW42WXVlMD..." // VC2
],
"@context":["https://www.w3.org/2018/credentials/v1"],
"type":["VerifiablePresentation"]
}
}.[signature]
VC2は、self-signのVCでスマホ上で撮影された顔写真の動画が入っている。ここはスマホで照合してその結果だけがverifier側に渡るものだと思っていたので、意外な動きであった、恐らくpreviewer版だからかなと考えられる。というのも、MS authenticator内では、顔画像は共有されませんって書かれたUIが表示されているので、正式版ではその対応が図られるものと思われる。
[2024/02/12] https://learn.microsoft.com/ja-jp/entra/verified-id/using-facecheck
顔チェックの生体認証ビジョン チェックはモバイル デバイスで実行されるのですか?
いいえ。 写真とキャプチャされたライブネス データの間の生体認証チェックは、Azure AI Vision Face API を使用してクラウドで実行されます。 プロセス中にユーザーがキャプチャした自撮りは、要求元の ID 検証サイトと共有されません。
--> verifierが「Request Service API 」を使うことを前提としたプライバシーモデルなのか...。
{
"kid": "did:ion:EiAn6Yue05YAACxU8mHts_83cm9AHuYDVO1ljEegAYAwgw:eyJkZWx0YSI...VfUTA4USJ9fQ#sign_G8Bzln9MJP",
"typ": "JWT",
"alg": "ES256K"
}.{
"sub": "did:ion:EiAn6Yue05YAACxU8mHts_83cm9AHuYDVO1ljEegAYAwgw:eyJkZWx0YSI...VfUTA4USJ9fQ",
"iss": "did:ion:EiAn6Yue05YAACxU8mHts_83cm9AHuYDVO1ljEegAYAwgw:eyJkZWx0YSI...VfUTA4USJ9fQ",
"iat": 1707043242467,
"jti": "30062CA9-846D-46D4-BA24-D3ABF3AE2F10",
"exp": 1707043542467,
"vc": {
"@context": [
"https://www.w3.org/2018/credentials/v1"
],
"type": [
"VerifiableCredential",
"LivenessCheck"
],
"credentialSubject": {
"metadata": "{\"metadata\":{\"imageData\":[{\"activeIlluminationInformation\":{\"illuminatedColor\":\"Illumination_Bright\"},\"fileName\":\"video.webp\",\"frameNumber\":0,\"imageOffsetsIfCropped\":{\"cropOffsetX\":0,\"cropOffsetY\":0,\"fullFrameImageHeight\":1920,\"fullFrameImageWidth\":1080},\"imageProperties\":{\"exposureTimeInSeconds\":null,\"fNumber\":null,\"isoSpeed\":-1},\"imageTimestampInMilliseconds\":1707043241676,\"targetFaceRectangle\":{\"height\":639,\"left\":250,\"top\":744,\"width\":650},\"timeOffsetWithinFileInMilliseconds\":0},{\"activeIlluminationInformation\":{\"illuminatedColor\":\"Illumination_Bright\"},\"fileName\":\"video.webp\",\"frameNumber\":1,\"imageOffsetsIfCropped\":{\"cropOffsetX\":0,\"cropOffsetY\":0,\"fullFrameImageHeight\":1920,\"fullFrameImageWidth\":1080},\"imageProperties\":{\"description\":\"Nose\",\"exposureTimeInSeconds\":null,\"fNumber\":null,\"isoSpeed\":-1},\"imageTimestampInMilliseconds\":1707043241677,\"targetFaceRectangle\":{\"height\":218,\"left\":416,\"top\":939,\"width\":218},\"timeOffsetWithinFileInMilliseconds\":1},{\"activeIlluminationInformation\":{\"illuminatedColor\":\"Illumination_Bright\"},\"fileName\":\"video.webp\",\"frameNumber\":2,\"imageOffsetsIfCropped\":{\"cropOffsetX\":0,\"cropOffsetY\":0,\"fullFrameImageHeight\":1920,\"fullFrameImageWidth\":1080},\"imageProperties\":{\"description\":\"LeftEye\",\"exposureTimeInSeconds\":null,\"fNumber\":null,\"isoSpeed\":-1},\"imageTimestampInMilliseconds\":1707043241678,\"targetFaceRectangle\":{\"height\":94,\"left\":318,\"top\":883,\"width\":217},\"timeOffsetWithinFileInMilliseconds\":2},{\"activeIlluminationInformation\":{\"illuminatedColor\":\"Illumination_Bright\"},\"fileName\":\"video.webp\",\"frameNumber\":3,\"imageOffsetsIfCropped\":{\"cropOffsetX\":0,\"cropOffsetY\":0,\"fullFrameImageHeight\":1920,\"fullFrameImageWidth\":1080},\"imageProperties\":{\"description\":\"RightEye\",\"exposureTimeInSeconds\":null,\"fNumber\":null,\"isoSpeed\":-1},\"imageTimestampInMilliseconds\":1707043241679,\"targetFaceRectangle\":{\"height\":93,\"left\":548,\"top\":873,\"width\":217},\"timeOffsetWithinFileInMilliseconds\":3},{\"activeIlluminationInformation\":{\"illuminatedColor\":\"Illumination_Dark\"},\"fileName\":\"video.webp\",\"frameNumber\":4,\"imageOffsetsIfCropped\":{\"cropOffsetX\":0,\"cropOffsetY\":0,\"fullFrameImageHeight\":1920,\"fullFrameImageWidth\":1080},\"imageProperties\":{\"exposureTimeInSeconds\":null,\"fNumber\":null,\"isoSpeed\":-1},\"imageTimestampInMilliseconds\":1707043241745,\"targetFaceRectangle\":{\"height\":642,\"left\":248,\"top\":742,\"width\":655},\"timeOffsetWithinFileInMilliseconds\":69},{\"activeIlluminationInformation\":{\"illuminatedColor\":\"Illumination_Dark\"},\"fileName\":\"video.webp\",\"frameNumber\":5,\"imageOffsetsIfCropped\":{\"cropOffsetX\":0,\"cropOffsetY\":0,\"fullFrameImageHeight\":1920,\"fullFrameImageWidth\":1080},\"imageProperties\":{\"description\":\"Nose\",\"exposureTimeInSeconds\":null,\"fNumber\":null,\"isoSpeed\":-1},\"imageTimestampInMilliseconds\":1707043241746,\"targetFaceRectangle\":{\"height\":219,\"left\":420,\"top\":934,\"width\":219},\"timeOffsetWithinFileInMilliseconds\":70},{\"activeIlluminationInformation\":{\"illuminatedColor\":\"Illumination_Dark\"},\"fileName\":\"video.webp\",\"frameNumber\":6,\"imageOffsetsIfCropped\":{\"cropOffsetX\":0,\"cropOffsetY\":0,\"fullFrameImageHeight\":1920,\"fullFrameImageWidth\":1080},\"imageProperties\":{\"description\":\"LeftEye\",\"exposureTimeInSeconds\":null,\"fNumber\":null,\"isoSpeed\":-1},\"imageTimestampInMilliseconds\":1707043241747,\"targetFaceRectangle\":{\"height\":94,\"left\":319,\"top\":880,\"width\":219},\"timeOffsetWithinFileInMilliseconds\":71},{\"activeIlluminationInformation\":{\"illuminatedColor\":\"Illumination_Dark\"},\"fileName\":\"video.webp\",\"frameNumber\":7,\"imageOffsetsIfCropped\":{\"cropOffsetX\":0,\"cropOffsetY\":0,\"fullFrameImageHeight\":1920,\"fullFrameImageWidth\":1080},\"imageProperties\":{\"description\":\"RightEye\",\"exposureTimeInSeconds\":null,\"fNumber\":null,\"isoSpeed\":-1},\"imageTimestampInMilliseconds\":1707043241748,\"targetFaceRectangle\":{\"height\":95,\"left\":552,\"top\":871,\"width\":220},\"timeOffsetWithinFileInMilliseconds\":72}],\"imageType\":0,\"version\":\"1.0\"}}"
"content": "UklGRvzaAwBXR....jdVAgAAAA" //--スマホで撮影された顔写真の動画
}
}
}.[signature]
2.4 verifierで検証
previewe版だと、postされてきたVPの中からVC1, VC2を読み出し、VC1の中のphoto claimと、VC2の顔画像の動画を照合し、OK/NGを返す処理イメージである。
体験してみよう
1. index.html, server.js, package.jsを以下のフォルダ構成のように配置する
├── server.js
├── package.json
└── views/
├── index.html
index.htmlのコードを見る
<!DOCTYPE html>
<html lang='en'>
<head><meta charset='utf-8'></head>
<body>
<div align=center>
<div id="title">[ngrok registration & verifier]</div>
<table border=1><tr><td>
<div id="parameter">given name:<input type="text" id="given" value="hogehoge"><br>
family name:<input type="text" id="family" value="deadbeaf"><br>
photo:<button id="switchFrontBtn" onclick="switchCamera('user'); return false;"">Take a selfie</button>
<button id="snapBtn" onclick="takePicture(); event.preventDefault() ; return false;" style="display: none;" class="btn btn-outline-warning">Snap</button><br>
<video id="cam" autoplay="" muted="" playsinline="" style="width: 200px; height: 200px; display: none;">Not available</video><canvas id="canvas" style="display:none"></canvas>
<img id="selfie" width="200" style="max-height: 200px; margin-top: 20px;" src=""><br>
<button id="clearBtn" style="display: none;" class="btn btn-link link-warning" onclick="clearPhoto();">Remove</button><b>
<input type="button" value="発行" onclick="issuerQR()"/><br>
<input type="hidden" id="imageUploadStr" class="form-control" data-val="true" data-val-required="The Photo field is required." name="Photo" value="">
<p><div id=qrmsg></div>
<p><div id=qr></div>
</div>
</td><td>
<input type="button" value="資格の検証" onclick="callQR()"/>
<p><div id=qrmsg2></div>
<p><div id=qr2></div>
</td></tr></table>
<p><div id=result1 style="word-break: break-word;" align=left width=90%></div>
<p><div id=result2 style="word-break: break-word;" align=left width=90%></div>
</body>
<script type="text/javascript">
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);
}
function issuerQR() {
document.getElementById("qr2").innerHTML="";
let photo = document.getElementById("selfie").src
let setting={
"clientName": "ngrok registration",
"type": "ngrokVerifiedCredential",
"claims": {"lastName": document.getElementById('family').value, "firstName": document.getElementById('given').value}
}
if (photo.length>2000){
setting.photo = photo
}
sendData("./.issuer_init", JSON.stringify(setting), 'application/json',
function(data){
target = document.getElementById("qr");
target.innerHTML = "<img src="+JSON.parse(data).qrcode+" />";
let interval_id = setInterval(
function(){
console.log("request check status");
istatusCheck(interval_id, JSON.parse(data).sess);
}, 3000);
}
);
}
function callQR() {
document.getElementById("qr").innerHTML="";
getData("./.callQR", 'application/json',
function(data){
target = document.getElementById("qr2");
target.innerHTML = "<img src="+JSON.parse(data).qrcode+" />";
let interval_id = setInterval(
function(){
console.log("request check status");
vstatusCheck(interval_id, JSON.parse(data).sess);
}, 3000);
});
}
function istatusCheck(clearid, sess){
getData("./.istatus/"+sess, 'application/json',
function(data){
console.log("status:"+JSON.parse(data).status);
if (JSON.parse(data).status == "request_retrieved"){
let msg = "QR code sccand. waiting..";
if (typeof JSON.parse(data).pin !=="undefined"){ msg +="PIN:"+JSON.parse(data).pin;}
document.getElementById("qrmsg").innerHTML =msg;
}else if(JSON.parse(data).status == "issuance_successful"){
document.getElementById("qrmsg").innerHTML = "finish";
document.getElementById("qr").innerHTML = "";
clearInterval(clearid);
}
}
);
}
function vstatusCheck(clearid, sess){
getData("./.vstatus/"+sess, 'application/json',
function(data){
console.log("status:"+JSON.parse(data).status);
if (JSON.parse(data).status == "wait"){
document.getElementById("qrmsg2").innerHTML = "cheking....";
}else if(JSON.parse(data).status == "get"){
document.getElementById("qrmsg2").innerHTML = "finish";
document.getElementById("qr2").innerHTML = "";
console.log(data);
let id_token = JSON.parse(data).token.id_token;
let vp_token = JSON.parse(data).token.vp_token;
document.getElementById("result1").innerHTML = "<b>[token]:</b><br><b>ID token</b>:<br><blockquote>"+JSON.stringify(id_token)+"</blockquote>";
document.getElementById("result2").innerHTML = "<b>VP_token</b>:<br><blockquote>"+JSON.stringify(vp_token)+"</blockquote>";
clearInterval(clearid);
}
}
);
}
</script>
<script>
// Source code: https://codepen.io/ocinpp/pen/EpbXKz
// reference to the current media stream
var mediaStream = null;
// Prefer camera resolution nearest to 1280x720.
var constraints = {
audio: false,
video: {
width: { ideal: 640 },
height: { ideal: 480 },
facingMode: "environment"
}
};
async function getMediaStream(constraints) {
try {
mediaStream = await navigator.mediaDevices.getUserMedia(constraints);
let video = document.getElementById('cam');
video.srcObject = mediaStream;
video.onloadedmetadata = (event) => {
video.play();
};
} catch (err) {
console.error(err.message);
}
};
async function switchCamera(cameraMode) {
try {
// stop the current video stream
stopVideoStream();
// Show the video element
document.getElementById('cam').style.display = '';
// change "facingMode"
constraints.video.facingMode = cameraMode;
// Show the snap button and hide the start video button
document.getElementById('snapBtn').style.display = '';
document.getElementById('switchFrontBtn').style.display = 'none';
// Hide the selfie image
document.getElementById('selfie').style.display = 'none';
// get new media stream
await getMediaStream(constraints);
} catch (err) {
console.error(err.message);
alert(err.message);
}
}
function takePicture() {
let canvas = document.getElementById('canvas');
let video = document.getElementById('cam');
let selfie = document.getElementById('selfie');
let context = canvas.getContext('2d');
const height = video.videoHeight;
const width = video.videoWidth;
if (width && height) {
canvas.width = width;
canvas.height = height;
context.drawImage(video, 0, 0, width, height);
var data = canvas.toDataURL('image/jpeg');
selfie.setAttribute('src', data);
// Hide the video
document.getElementById('cam').style.display = 'none';
// Hide the snap button and show the start video button
document.getElementById('snapBtn').style.display = 'none';
document.getElementById('switchFrontBtn').style.display = '';
// Show the image and the remove button
document.getElementById('selfie').style.display = '';
document.getElementById('clearBtn').style.display = '';
stopVideoStream();
// Get the image URL in base64 format
getImageULR();
} else {
clearPhoto();
}
}
function stopVideoStream() {
// Stop the current video stream
if (mediaStream != null && mediaStream.active) {
var tracks = mediaStream.getVideoTracks();
tracks.forEach(track => {
track.stop();
})
}
// set the video source to null
document.getElementById('cam').srcObject = null;
}
function clearPhoto() {
let canvas = document.getElementById('canvas');
let selfie = document.getElementById('selfie');
let context = canvas.getContext('2d');
context.fillStyle = "black";
context.fillRect(0, 0, canvas.width, canvas.height);
var data = canvas.toDataURL('image/jpeg');
selfie.setAttribute('src', data);
// Hide the remove button
document.getElementById('clearBtn').style.display = 'none';
}
function getImageULR(){
var indexOfBase64 = document.getElementById("selfie").src.indexOf("base64,");
document.getElementById("imageUploadStr").value = document.getElementById("selfie").src.substring(indexOfBase64 + 7);
}
clearPhoto();
</script>
</html>
package.jsonのコードを見る
{
"dependencies": {
"did-jwt": "^8.0.0",
"elliptic": "^6.5.4",
"express": "^4.18.2",
"qrcode": "^1.5.3",
"uuid": "^9.0.1",
"web-did-resolver": "^2.0.27"
}
}
server.jsのコードを見る
const express = require('express');
const bodyParser = require('body-parser');
const app = express();
const http = require('http');
const QRCode = require('qrcode');
const crypto = require('crypto');
const elliptic = require('elliptic');
const didJWT = require('did-jwt');
const fs = require('fs');
const uuid = require('uuid');
app.use(bodyParser.json({ extended: true, limit: '10mb' }));
app.use(bodyParser.urlencoded({ extended: true, limit: '10mb' }));
app.use(express.json({ extended: true, limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
app.use('/', express.static(__dirname + '/views/'));
app.use(bodyParser.text({type: '*/*'}));
app.use(bodyParser.raw({type: '*/*'}));
//*******setting********
const port=8888;
let hostname = '127.0.0.1'+":"+port;
const pinenable=false;
const method="self" // "attest"
if (process.argv.length > 2){ hostname = process.argv[2];}
// authentication by azure directory
const directory = "https://login.microsoftonline.com/<Tenant id>/v2.0";
//**********************
//*******global variable*******
let DID="did:web:"+hostname;
let task= new Array();
let taskstatus= new Array();
let storageJson=[];
let didweb, didwebkey;
const main = async()=>{
didwebkey = await init(hostname);
didweb = didjson(DID, didwebkey, hostname)
}
main();
//******************************
//*******web server*******
const httpServer = http.createServer(app);
const server = httpServer.listen(port, () => {
console.log('\n web page start: listening on port %s...', server.address().port);
});
app.get('/.*', async function(req,res){
console.log("\n====GET====");
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("====GET====");
let ret="";
let cmd = req.params[0].split('/');
if (cmd[0] =="well-known" && cmd[1] == "did.json"){
console.log("access to did.json")
res.send(didweb);
} else if (cmd[0] =="well-known" && cmd[1] == "did-configuration.json"){
console.log("access to did-configuration.json")
res.send(await makedidconfig());
} else if (cmd[0] == "issuanceRequests"){
console.log("start session:"+cmd[1]+", issuanceRequests")
taskstatus[cmd[1]].vcstatus = "request_retrieved";
res.send(await issuevcrequest(task[cmd[1]]));
} else if(cmd[0] == "contracts" && cmd[2] == "manifest"){
console.log("manifest for :"+cmd[1]);
res.send(await makemanifest(task[cmd[1]],method));
} else if (cmd[0] =="istatus"){
ret={"status":taskstatus[cmd[1]].vcstatus};
if (taskstatus[cmd[1]].vcstatus =="request_retrieved"){
if (pinenable && (method !="attest")){
ret.pin=task[cmd[1]].pin;
}
}
res.send(JSON.stringify(ret));
} else if (cmd[0] =="callQR"){
let [redirecturi, sessNo] = await verifierInit(req.query);
inviteUrl = "openid-vc://?request_uri="+redirecturi;
QRCode.toDataURL(inviteUrl, function (err, url) {
let sendData = {"qrcode":url, "sess":sessNo, "url":redirecturi};
res.send(sendData);
});
} else if (cmd[0]== "presentationRequests"){
console.log(cmd[1])
if (typeof task[cmd[1]] !=="undefined"){
console.log("VC request push");
let jwt = await presenvcrequest(task[cmd[1]]);
console.log(`//// JWT:\n${jwt}`)
const decoded = didJWT.decodeJWT(jwt)
console.log('\n//// JWT Decoded:\n',decoded)
res.send(jwt);
}else{
rese.send("");
}
} else if (cmd[0]=='vstatus'){
let ret={};
if (taskstatus[cmd[1]].vp !=""){
taskstatus[cmd[1]].vcstatus = "get";
let tokenraw = taskstatus[cmd[1]].vp;
let token ={}
token.id_token = didJWT.decodeJWT(tokenraw.id_token);
delete token.id_token["data"]
token.vp_token = didJWT.decodeJWT(tokenraw.vp_token);
delete token.vp_token["data"]
ret.token = token;
}
ret.status =taskstatus[cmd[1]].vcstatus;
res.send(JSON.stringify(ret));
}else{
res.send(ret);
}
});
app.post('/.*', async function(req,res){
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"){
let [redirecturi, sessNo] = await issuerInit(req.body);
let inviteUrl = "openid-vc://?request_uri=" + redirecturi;
QRCode.toDataURL(inviteUrl, function (err, url) {
let sendData = {"qrcode":url, "sess":sessNo, "url":redirecturi};
res.send(sendData);
});
}else if (cmd[0] == "issue"){
console.log("post from ms authenticator:")
let decoded = didJWT.decodeJWT(req.body);
let token;
if (method == "attest"){
token = decoded.payload.attestations.accessTokens[directory]
}else{
token = decoded.payload.attestations.idTokens["https://self-issued.me"]
}
let sess = cmd[1];
if ((pinenable && decoded.payload.pin == task[sess].extpin) || (method=="attest")||!pinenable){
let ret = await createVc(decoded, task[sess]);
if (task[sess].iss=="did"){
await saveDid(task[sess].issdid, task[sess].isskey);
}
res.send(JSON.stringify(ret));
}else{
console.log("pin is wrong, return error!");
let sendData = {
"requestId":"","date":"","mscv":"",
"error":{
"code":"unauthorized",
"message":"The requested resource requires authentication",
"innererror":{
"code":"Unauthorized",
"message":"An unhandled error has occurred verifying a token."
}
}
};
res.send(JSON.stringify(sendData));
}
}else if (cmd[0] == "completeIssuance"){
// server <--- ms authenticator
if (req.body.code == 'issuance_successful'){
console.log("vc issue is success");
}else{
console.log("vc issue is failed");
}
console.log("state:"+req.body.state);
taskstatus[req.body.state].vcstatus="issuance_successful";
res.status(202).send('OK')
}else if (cmd[0]=="presentationRequests"){
if (typeof taskstatus[cmd[1]] !=="undefined"){
taskstatus[cmd[1]].vp = req.body
let id_token = req.body.id_token;
let vp_token = req.body.vp_token;
let decoded = didJWT.decodeJWT(id_token);
console.log('\n//// id_token:\n',JSON.stringify(decoded))
decoded = didJWT.decodeJWT(vp_token);
console.log('\n//// vp_token:\n',JSON.stringify(decoded))
let vc=decoded.payload.vp.verifiableCredential[0];
decoded = didJWT.decodeJWT(vc);
console.log('\n//// vc:\n',JSON.stringify(decoded))
res.status(202).send('OK')
}else{
res.status(500);
}
}else{
res.send(null);
}
});
//////////////////////
// support function
///////////////////////
async function init(_hostname){
let _did = "did:web:"+_hostname;
let _key = await loadDid(_did);
if (_key ==null){
console.log("\n this "+_did+"'s key not registered")
let [_key, , _document] = await createDid("web", _hostname);
await saveDid(_did, _key);
return _key;
}else{
console.log("\n this "+_did+"'s key is registered")
return _key;
}
}
async function loadDid(_did){
let key = null;
try{
storageJson = JSON.parse(fs.readFileSync('./storage.json'));
storageJson.forEach((item) =>{
if (item.did == _did) key = item.key
})
return key;
}catch(err){
return null;
}
}
async function saveDid(_did, _key){
storageJson.push({did:_did, key:_key})
try{
fs.writeFileSync("./storage.json", JSON.stringify(storageJson))
}catch(err){
console.error(err);
}
}
async function createDid(_method, _hostname){
const key = crypto.randomBytes(32).toString("hex");
const ec = new elliptic.ec('secp256k1');
const prv = ec.keyFromPrivate(key, 'hex');
const pub = prv.getPublic();
const ecjwk = {
kty:"EC",
crv:"secp256k1",
"x":pub.x.toBuffer().toString('base64'),
"y":pub.y.toBuffer().toString('base64')
};
const ecjwkpri = {
kty:"EC",
crv:"secp256k1",
"x":pub.x.toBuffer().toString('base64'),
"y":pub.y.toBuffer().toString('base64'),
"d":prv.getPrivate().toBuffer().toString('base64')
};
console.log("--------generate key for did -------------------")
console.log(` d : ${prv.getPrivate().toBuffer().toString('base64')}`);
console.log(` x (b64): ${pub.x.toBuffer().toString('base64')}`);
console.log(` y (b64): ${pub.y.toBuffer().toString('base64')}`);
console.log("-----------------------------\n")
let _key={"publicJwk":ecjwk,"privateJwk":ecjwkpri};
let _document
let _did
if (_method == "web"){
// web method
_did = "did:web"+_hostname;
_document =didjson(_did, _key, _hostname);
console.log("-> "+JSON.stringify(_document));
}
return [_key, _did, _document];
}
function createSigner(_method, _key){
let _signer = didJWT.ES256KSigner(didJWT.hexToBytes(Buffer.from(didwebkey.privateJwk.d, 'base64').toString('hex')))
return _signer;
}
/////////////////////////
// did:web
/////////////////////////
function didjson(_did, _key, _hostname){
let _document = {
"id":"",
"@context":["https://www.w3.org/ns/did/v1",{"@base":""}],
"service":[
{"id":"#linkeddomains","type":"LinkedDomains","serviceEndpoint":{"origins":""}}
],
"verificationMethod":[
{"id":"#owner","controller":"",
"type":"EcdsaSecp256k1VerificationKey2019",
"publicKeyJwk":{"crv":"secp256k1","kty":"EC","x":"","y":""}
}
],
"authentication":["#owner"],"assertionMethod":["#owner"]
};
_document.id = _did;
_document["@context"][1]["@base"]=_did;
_document.service[0].serviceEndpoint.origins = ["https://"+_hostname+"/"];
_document.verificationMethod[0].controller = _did;
_document.verificationMethod[0].publicKeyJwk = _key.publicJwk;
return _document;
}
async function makedidconfig(){
let didconfig = {
"@context":"https://identity.foundation/.well-known/contexts/did-configuration-v0.0.jsonld",
"linked_dids":[]
};
let configPayload = {
"sub":didweb.id,
"iss":didweb.id,
"nbf":1674647331,
"vc":{
"@context":[
"https://www.w3.org/2018/credentials/v1",
"https://identity.foundation/.well-known/contexts/did-configuration-v0.0.jsonld"
],
"issuer":didweb.id,
"issuanceDate":"2023-01-25T11:48:51.998Z",
"expirationDate":"2048-01-25T11:48:51.998Z",
"type":["VerifiableCredential","DomainLinkageCredential"],
"credentialSubject":{"id":didweb.id,"origin":didweb.service[0].serviceEndpoint.origins[0]}
}
};
let signer = didJWT.ES256KSigner(didJWT.hexToBytes(Buffer.from(didwebkey.privateJwk.d, 'base64').toString('hex')))
const configjwt = await didJWT.createJWT(
configPayload,
{ issuer:DID,expiresIn:3600,signer },
{ alg: 'ES256K', kid: DID+"#owner" }
)
didconfig.linked_dids[0]=configjwt;
return didconfig;
}
////////////////////////////
// vc issuer
/////////////////////////////
async function issuerInit(_body){
let sessNo = crypto.randomBytes(8).toString("hex");
let serviceEndpoint = "https://"+hostname+"/";
task[sessNo]= {
"sess":sessNo,
"userid":crypto.randomBytes(32).toString("base64"),
"clientName":_body.clientName,
"type":_body.type,
"claims":_body.claims,
"photo":_body.photo,
"serviceEndpoint":serviceEndpoint,
"isskey": didwebkey,
"issdid": DID
};
if (pinenable){
task[sessNo].pin = Math.random().toString(10).slice(-6);
task[sessNo].pinsalt = crypto.randomBytes(32).toString("hex");
task[sessNo].pinhash = "qXp47rTIcPN2rfAz/sCKDAVNgFzKG7bTzVnCjWg5suU=";
let pinsha256 = crypto.createHash('sha256');
pinsha256.update(task[sessNo].pinsalt+task[sessNo].pin);
task[sessNo].extpin = pinsha256.digest('base64');
}
let redirecturi = task[sessNo].serviceEndpoint+".issuanceRequests/"+sessNo;
taskstatus[sessNo]={"vcstatus":"wait"};
return [redirecturi, sessNo];
}
async function issuevcrequest(_param){
let hintbody ={
"id":uuid.v4(),
"sub":_param.userid,
"aud":_param.serviceEndpoint+".issue/"+_param.sess,
"nonce":crypto.randomBytes(16).toString("base64"),
"sub_jwk":{
"crv":"secp256k1",
"kid":_param.issdid+"#owner",
"kty":"EC",
"x":_param.isskey.publicJwk.x,
"y":_param.isskey.publicJwk.y
},
"did":_param.issdid,
"type":_param.type,
"credentialSubject":{
"lastName":_param.claims.lastName,
"firstName":_param.claims.firstName
},
"iss":"https://self-issued.me",
"jti":uuid.v4().toUpperCase()
};
const signer = createSigner(_param.iss, _param.isskey);
const hint_jwt = await didJWT.createJWT(
hintbody,
{ issuer:"https://self-issued.me", expiresIn:3600, signer },
{ alg: 'ES256K', kid: _param.issdid+"#owner" }
);
if (pinenable){
hintbody.pin = {"length":6,"type":"numeric","alg":"sha256","iterations":1,"salt":_param.pinsalt, "hash":_param.pinhash};
}
if (typeof _param.photo !=="undefined"){
console.log("add a photo");
hintbody.credentialSubject.photo = _param.photo
}
let isbody ={
"jti":uuid.v4().toUpperCase(),
"response_type":"id_token",
"response_mode":"post",
"scope":"openid",
"nonce":crypto.randomBytes(16).toString("base64"),
"client_id":_param.issdid,
"redirect_uri":_param.serviceEndpoint+".completeIssuance",
"prompt":"create",
"state":_param.sess,
"registration":{
"client_name":_param.clientName,
"subject_syntax_types_supported":["did:ion"],
"vp_formats":{
"jwt_vp":{"alg":["ES256K"]},"jwt_vc":{"alg":["ES256K"]}
}
},
"claims":{
"vp_token":{
"presentation_definition":{
"id":"b1cb4efd-8e33-482f-8dad-4452cbc2a8d1",
"input_descriptors":[{
"id":"Sample",
"schema":[{"uri":"Sample"}],
"issuance":[{"manifest":_param.serviceEndpoint+".contracts/"+_param.sess+"/manifest"}]
}]
}
}
},
"id_token_hint":hint_jwt
};
if (pinenable){
isbody.pin = {"length":6,"type":"numeric","alg":"sha256","iterations":1,"salt":_param.pinsalt, "hash":_param.pinhash};
}
const jwt = await didJWT.createJWT(
isbody,
{ issuer:_param.issdid+"#owner", expiresIn:3600, signer },
{ alg: 'ES256K', kid: _param.issdid+"#owner" }
)
return jwt;
}
async function createVc(_decoded, _param){
let idToken
if (typeof _decoded.payload.attestations.idTokens !=="undefined"){
idToken = _decoded.payload.attestations.idTokens["https://self-issued.me"];
}else{
idToken = _decoded.payload.attestations.accessTokens[directory];
}
console.log("\n-> idToken:");
const subject = didJWT.decodeJWT(idToken).payload
let vcbody = {
"jti": _decoded.payload.jti,
"vc": {
"@context": ["https://www.w3.org/2018/credentials/v1"],
"type": ["VerifiableCredential", "StudentCard"],
"credentialSubject": {},
"exchangeService": {
"id": _param.serviceEndpoint+".card/exchange",
"type": "PortableIdentityCardServiceExchange2020"
},
"sub": _decoded.header.kid
}
};
if (method=="attest"){
vcbody.vc.credentialSubject.lastName = subject.family_name;
vcbody.vc.credentialSubject.firstName = subject.given_name;
}else{
vcbody.vc.credentialSubject = subject.credentialSubject;
}
if (typeof _param.photo !=="undefined"){
console.log("add photo")
vcbody.vc.credentialSubject.photo = _param.photo.split("base64,")[1];
}
vcbody.vc.credentialStatus = await makeCredStatus(vcbody.vc, _param.issdid, new Date().toISOString());
let signer = createSigner(_param.iss, _param.isskey);
const jwt = await didJWT.createJWT(
vcbody,
{ issuer:_param.issdid, expiresIn:3600, signer },
{ alg: 'ES256K', kid: _param.issdid+"#owner" }
)
const decoded = didJWT.decodeJWT(jwt)
console.log('\n//// JWT Decoded:\n',decoded)
let sendData = {"vc":jwt}
return sendData;
}
async function makeCredStatus(_vc, _iss, _date){
let credStatus = {
"id": "https://xxxx.github.io/credential-status/3BOJ1LAFUS#26",
"type": "StatusList2021Entry",
"statusPurpose": "revocation",
"statusListIndex": 26,
"statusListCredential": "https://xxxx.github.io/credential-status/3BOJ1LAFUS"
}
return credStatus;
}
async function makemanifest(_param, _method){
let manifestbody = {
"id": _param.sess,
"display": {
"locale": "en-US",
"contract": _param.serviceEndpoint+".contracts/"+_param.sess+"/manifest",
"card": {
"title": "ID card",
"issuedBy": "ngrok regitration",
"backgroundColor": "#ffffff",
"textColor": "#000000",
"logo": {
"uri": "https://didcustomerplayground.blob.core.windows.net/public/VerifiedCredentialExpert_icon.png",
"description": "Verified skill Card"
},
"description": "Use your verified credential to prove to anyone that you know all about verifiable credentials."
},
"consent": {
"title": "Do you want to get your Verified Credential?",
"instructions": "Sign in with your account to get your card."
},
"claims": {
"vc.credentialSubject.firstName": {"type": "String","label": "first Name"},
"vc.credentialSubject.lastName": {"type": "String","label": "last Name"},
},
"id": "display"
},
"input": {
"credentialIssuer": _param.serviceEndpoint+".issue/"+_param.sess,
"issuer": DID,
"id": "input"
}
};
if (typeof _param.photo!=="undefined"){
manifestbody.display.claims["vc.credentialSubject.photo"]={"type": "image/jpg;base64url","label": "User picture"};
}
if (_method == "attest"){
manifestbody.input.attestations = {
"accessTokens": [{
"id": directory,
"encrypted": false,
"claims": [],
"required": true,
"configuration": directory,
"resourceId": "bb2a64ee-5d29-4b07-a491-25806dc854d3",
"oboScope": "User.Read.All"
}]
};
}else{
manifestbody.input.attestations = {
"idTokens": [{
"id": "https://self-issued.me",
"encrypted": false,
"claims": [{
"claim": "$.lastName",
"required": true,
"indexed": false
},{
"claim": "$.firstName",
"required": true,
"indexed": false
}],
"required": true,
"configuration": "https://self-issued.me",
"client_id": "",
"redirect_uri": ""
}]
};
}
if (typeof _param.photo!=="undefined"){
manifestbody.input.attestations.idTokens[0].claims.push({"claim": "photo","required": false,"indexed": false});
}
let signer = createSigner("web", didwebkey);
const jwt = await didJWT.createJWT(
manifestbody,
{ issuer:DID, expiresIn:3600, signer },
{ alg: 'ES256K', kid: DID+"#owner" }
)
const decoded = didJWT.decodeJWT(jwt)
return JSON.stringify({"token":jwt})
}
//////////////////
/// verifier
/////////////////
async function verifierInit(_query){
let sessNo = crypto.randomBytes(8).toString("hex");
let serviceEndpoint = "https://"+hostname+"/";
task[sessNo]= {
"sess":sessNo,
"userid":crypto.randomBytes(32).toString("base64"),
"clientName":_query.clientName,
"type":_query.type,
"serviceEndpoint":serviceEndpoint,
"isskey": didwebkey,
"issdid": DID
};
let redirecturi = task[sessNo].serviceEndpoint+".presentationRequests/"+sessNo;
taskstatus[sessNo]={"vcstatus":"wait", vp:""};
return [redirecturi, sessNo];
}
async function presenvcrequest(_param){
let payload = {
"jti":uuid.v4(),
"response_type":"id_token",
"response_mode":"post",
"scope":"openid",
"nonce":crypto.randomBytes(16).toString("base64"),
"client_id":"",
"redirect_uri":"",
"state":"",
"registration":{
"client_name":"839 home",
"subject_syntax_types_supported":["did:ion", "did:key", "did:jwk"],
"vp_formats":{
"jwt_vp":{"alg":["ES256K","EdDSA"]},
"jwt_vc":{"alg":["ES256K","EdDSA"]}
}
},
"claims":{
"vp_token":{
"presentation_definition":{
"id":uuid.v4(),
"input_descriptors":[{
"id":"Any student card",
"purpose":"So we can see accept any studentCard scheme",
"schema":[{"uri":"StudentCard"}],
},{
"id": uuid.v4(),
"name": "LivenessCheck",
"schema": [{
"uri": "LivenessCheck"
}]
}]
}
}
}
};
payload.client_id=DID;
payload.redirect_uri = "https://"+hostname+"/.presentationRequests/"+_param.sess
payload.state = "123456789";
const signer = createSigner(_param.iss, _param.isskey);
let jwt = await didJWT.createJWT(
payload,
{ issuer:DID+"#owner", expiresIn:3600, signer },
{ alg: 'ES256K', kid: DID+"#owner" }
)
return jwt;
}
2. 関連モジュールをインストールする
> npm install
3. サーバに割り当てるドメイン名を取得
> ngrok http 8881
Region United States (us)
Web Interface http://127.0.0.1:4040
Forwarding http://57dd-180-53-77-xxx.ngrok-free.app -> http://localhost:8881
Forwarding https://57dd-180-53-77-xxx.ngrok-free.app -> http://localhost:8881
4. 取得したドメイン名を引数に、サーバを起動する
> nodejs server.js 57dd-180-53-77-xxx.ngrok-free.app
web page start: listening on port 8881...
this did:web:57dd-180-53-77-xxx.ngrok-free.app's key not registered
--------generate key for did -------------------
d : 0tr5cu9T9f6Xz0H6Mwv0gwGM3NxSGTAzcOMcHS/sRnk=
x (b64): 0G5yn2kRKSsPWcWIPQCX9YFJY0k4tOlucU4XR932VA0=
y (b64): LCk2FkTpjLpvhnfWGOb+sn0drRof2aJl56VFDp8pook=
-----------------------------
-> {"id":"did:web57dd-180-53-77-xxx.ngrok-free.app","@context":["https://www.w3.org/ns/did/v1",{"@base":"did:web57dd-180-53-77-202.ngrok-free.app"}],"service":[{"id":"#linkeddomains","type":"LinkedDomains","serviceEndpoint":{"origins":["https://57dd-180-53-77-202.ngrok-free.app/"]}}],"verificationMethod":[{"id":"#owner","controller":"did:web57dd-180-53-77-xxx.ngrok-free.app","type":"EcdsaSecp256k1VerificationKey2019","publicKeyJwk":{"kty":"EC","crv":"secp256k1","x":"0G5yn2kRKSsPWcWIPQCX9YFJY0k4tOlucU4XR932VA0=","y":"LCk2FkTpjLpvhnfWGOb+sn0drRof2aJl56VFDp8pook="}}],"authentication":["#owner"],"assertionMethod":["#owner"]}