【個人開発者向け】秘密鍵なしでブロックチェーンモバイルアプリを作る

松本一将
Opening Line
Published in
17 min readAug 31, 2022

記事の前に

平素よりお世話になります。株式会社Opening Lineの松本です。この度は新しい試みとして「ゴーストライター制度」を導入しました。記載しているアカウントは僕ですが、さまざまな事情から名前やTwitterアカウントを公表したくない人向けに記事作成ができる制度を実施しております。

こちら質問等は僕のアカウントまでいただけますと、実際の記事作成者にお届けしますので、よろしくお願いいたします。

質問は下記まで。

https://twitter.com/kazumasamatsumo

はじめに

モバイルアプリでブロックチェーンの開発をする際に新規にブロックチェーンのアカウントアドレスを作成するのが一般的です。
しかし自分が保有しているNFTを表示したいとか、特定のトークンを持っていることを証明したいという場合、アカウントの秘密鍵をアプリに入れてもらう必要が出てきます。

秘密鍵の管理は基本的に自己責任とは言いながらも、漏洩したりすればアプリの評判に響きます。
また、Appleの審査を受ける場合も、秘密鍵を保存する場合法人アカウントを取得する必要があり個人開発者にとってハードルが高くなります。

このようなユースケースに対して、秘密鍵を持たずにブロックチェーンアプリを作るための方法をお伝えします。
Symbolブロックチェーンを例に使っていますが、他のブロックチェーンでも参考になると思います。

目次

  1. 環境
  2. ログイン
  3. トランザクション
  4. まとめ
  5. 参考文献

環境

構築環境は以下の通りです

サーバーサイド:

・Firebase Cloud Functions
・Node.js 16.12.0

フロントエンド:

・Flutter 3.0.0

ログイン

アプリ内に秘密鍵を保持しない場合、別の方法でアカウントの認証をする必要があります。
今回は、端末に入っているウォレットを活用したログイン方法となります。

Symbolブロックチェーンでは、暗号化したメッセージを送る事ができます。
この機能を利用してサーバーから送られたPINコードを、ウォレット側から暗号化してサーバーに返す事がで認証を行います。

認証の流れ

  1. アプリでPINコードのリクエストを行います。
    この際に自分のアカウントアドレスも一緒に送ります。

2.サーバーは、PINコードを生成してアプリに返します。

3.ユーザは送られたきたPINコードと、管理者用アカウントアドレスを確認しログインリクエストを行います。

4.ログインリクエストを受けたサーバは、リクエストのあったアカウントアドレスのトランザクションを監視します。

5.端末でウォレットアプリを立ち上げ、PINコードを暗号化メッセージとして管理者アカウントアドレスに送ります。

6.サーバーは、リクエストのあったアカウントアドレスのトランザクションを監視しており
PINメッセージを複合後、照合できたら認証OKの旨をアプリ側に返します。

認証のポイント

・認証用のDBが必要ない
ブロックチェーンのデータおよび、暗号化されたメッセージを使って認証を行うので
メールアドレスやパスワードなどの個人情報をサービス提供者側で保持する必要がありません。

・なりすまし防止
サーバー側はブロックチェーン上から送られてきたアドレスと、PINコードの一致を確認する事で、なりすましを防止できます。

実際のコード

  1. アプリでログインのリクエストを行います。(Flutter)
Future<int> mobileRequestPin(String userAddress, String node) async {
var url = Uri.parse("${node}/accounts/${userAddress}"); //アカウントのチェック
http.Response res = await http.get(url);
if (json.decode(res.body)["code"] == "InvalidArgument") {
return 1;
} else {
try {
var url = Uri.parse(
"サーバー側APIエンドポイント");
Map<String, String> headers = {'content-type': 'application/json'};
http.Response res = await http.post(url, headers: headers); //PINコード要求
int resPin = json.decode(res.body)["pin"];
return resPin;
} catch (errorCode) {
return 0;
}
}
}

2.サーバーは、PINコードを生成してアプリに返します。(CloudFunctions Node.js)

exports.mobileRequestPin = functions.https.onRequest(async (req, res) => {
res.header('Access-Control-Allow-Origin', '*')
res.header('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE')
res.header(
'Access-Control-Allow-Headers',
'Content-Type, Authorization, access_token'
)
res.header('Cache-Control', 'private, no-cache, no-store, must-revalidate');
res.header('Expires', '-1');
res.header('Pragma', 'no-cache');

let num = Math.floor(Math.random() * (9999 + 1 - 1001)) + 1001;

res.status(200).send(JSON.stringify({pin: num}));
});

3.アプリはログイン処理の開始をサーバーに伝えます(Flutter)

Future<String> mobileLogin(String userAddress, String node, int pin, String adminAddress) async {
var wsNode = node.replaceFirst("https", "wss") + "/ws";
try {
var url = Uri.parse(
"サーバー側APIエンドポイント?userAddress=${userAddress}&node=${wsNode}&pin=${pin}&adminAddress=${adminAddress}");
Map<String, String> headers = {'content-type': 'application/json'};
http.Response res = await http.post(url, headers: headers);
if (res.statusCode == 400) {
return "timeOver";
} else if (res.statusCode == 401) {
return "invalidPin";
} else if (res.statusCode == 403) {
return "invalidPinFormat";
} else {
String resAddress = json.decode(res.body)["address"];
return resAddress;
}
} catch (errorCode) {
return "serverError";
}
}

4.サーバーはトランザクションを監視します(CloudFunctions Node.js)

exports.mobileLogin = functions.runWith({timeoutSeconds: 400,memory: "1GB"}).https.onRequest(async (req, res) => {
res.header('Access-Control-Allow-Origin', '*')
res.header('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE')
res.header(
'Access-Control-Allow-Headers',
'Content-Type, Authorization, access_token'
)
res.header('Cache-Control', 'private, no-cache, no-store, must-revalidate');
res.header('Expires', '-1');
res.header('Pragma', 'no-cache');

const dataJson = req.query
const userAddress = dataJson.userAddress;
const adminAddress = dataJson.adminAddress;
const node = dataJson.node;
const pin = dataJson.pin;

console.log(userAddress);
let ws = new WebSocket(node);
ws.onopen = function (e) {
}
ws.onmessage = function (event) {
response = JSON.parse(event.data);

if('uid' in response){
uid=response.uid;
body = '{"uid":"' + uid +'","subscribe":"block"}';
transaction= '{"uid":"'+uid+'","subscribe":"confirmedAdded/' + userAddress+ '"}'
ws.send(body);
ws.send(transaction);
}
if(response.topic=='confirmedAdded/'+ userAddress){
const unit8address = sym.Convert.hexToUint8(response.data.transaction.recipientAddress);
const rawAddress = sym.RawAddress.addressToString(unit8address);

const pubAcc = sym.PublicAccount.createFromPublicKey(response.data.transaction.signerPublicKey,sym.NetworkType.MAIN_NET);
const privatekey = functions.config().mainnet.privatekey
const adminAcc = sym.Account.createFromPrivateKey(
privatekey,
sym.NetworkType.MAIN_NET
);

const rawMsg = sym.Convert.decodeHex(response.data.transaction.message).slice(1);
if(rawMsg.length !== 64) res.status(403).send(); // not format pin

const encMsg = new sym.EncryptedMessage(rawMsg,pubAcc);
const msg = adminAcc.decryptMessage(encMsg,pubAcc).payload;

//msg末尾の空白を削除
if(msg.slice(-1)===" ") msg = msg.slice(0, -1);
if(rawAddress===adminAddress){
console.log('アドレスOK');
if(pin===msg){
console.log("認証成功");
ws.close();
res.status(200).send(JSON.stringify({address: userAddress}));
}else{
res.status(401).send(); // miss match pin
}
}
}
}

setTimeout(timeOver, 300000, ws); //5分以内に認証用メッセージが来なければタイムアウトとする
function timeOver(ws){
ws.close();
res.status(400).send(); // time over
}
});

トランザクション内のデータを解析する所を補足します。

const unit8address = sym.Convert.hexToUint8(response.data.transaction.recipientAddress);
const rawAddress = sym.RawAddress.addressToString(unit8address);
console.log(rawAddress);

トランザクション内のアドレスをプレーンなテキストアドレスに変換しています。

const rawMsg = sym.Convert.decodeHex(response.data.transaction.message).slice(1);
if(rawMsg.length !== 64) res.status(403).send(); // not format pin

メッセージの最初の1文字は暗号化しているかどうかの判別記号のようなものが入っているため、それを除外します。
また4文字のPINコード以外が入ってきた場合はエラーを返します。

//msg末尾の空白を削除
if(msg.slice(-1)===" ") msg = msg.slice(0, -1);

メッセージの末尾に空白が入っている場合があるので、もし空白があれば削除します。

トランザクション

ログインだけでなく、ウォレット側で署名を行い、アプリ側でトランザクションを監視することで
トークンの送受信や、その他様々なトランザクションを検知しアプリ側に反映する事ができます。
基本的にはサーバーでおこなっているトランザクションの監視をFutter側で実装する形になります。

Flutter側でのトランザクション監視開始と検知

Future<void> openWalletDialog(BuildContext context, String uid) async {
var wsNode = node.replaceFirst("https", "wss") + "/ws";
Timer wsTimer =
Timer(const Duration(seconds: 120), openWalletDialogTimer);
ws = IOWebSocketChannel.connect(Uri.parse(wsNode));
String wsUid = "";
ws.stream.listen((message) async {
if (message.contains('uid') && wsUid == "") {
Map jsonMassage = json.decode(message);
wsUid = jsonMassage["uid"];
Map body = {"uid": wsUid, "subscribe": "block"};
ws.sink.add(json.encode(body));
Map transaction = {
"uid": wsUid,
"subscribe": "confirmedAdded/${widget.uid}"
};
ws.sink.add(json.encode(transaction));
Map transaction2 = {
"uid": wsUid,
"subscribe": "unconfirmedAdded/${widget.uid}"
};
ws.sink.add(json.encode(transaction2));
}
if (message.contains("unconfirmedAdded/${widget.uid}") ||
message.contains("confirmedAdded/${widget.uid}")) {
print(message);
Map jsonMassage = json.decode(message);
String topic = jsonMassage["topic"];
//正しい相手に送ったトランザクションかどうか
Map data = jsonMassage["data"];
String recipientAddress = data["transaction"]["recipientAddress"];
String tmpAddress = base32.encodeHexString(recipientAddress);
String address = tmpAddress.substring(0, tmpAddress.length - 1);
if (address == uid) {
//自分の送った未承認トランザクションかどうか
if (topic == "unconfirmedAdded/${widget.uid}") {
print("送金しました。着金するのを待っています");
wsTimer.cancel();
}
//自分の送った承認済みトランザクションかどうか
if (topic == "confirmedAdded/${widget.uid}") {
print("着金しました");
if (ws != null) ws.sink.close(status.goingAway);
}
}
}
});
}

まとめ

いかがでしたでしょうか?

この方法のデメリットは、ログインを行うたびにトランザクション手数料がかかる点です(2022年8月現在で0.5円前後)
今後、認証のためのアプリなどが出て来れば嬉しいですがそれまではいろいろと試行錯誤しながらより良い方法を模索していきたいと思います。
記事の中でご不明な点や、もっとこうしたらいいのではないかと言う点があればコメントいただけると嬉しいです。

参考文献

--

--