symbol-sdk@3.2.3でv2方式の暗号化と復号化を


## はじめに

v2 と v3 のメッセージ暗号化そのもののフォーマットは同じなのですが、メッセージエリアへの格納方法が 16 進数文字列かバイナリかという点が違います。そのため、v3 で暗号化したメッセージはデスクトップウォレットで復号化出来ません。

v3 ベースのウォレットがまだ存在しないので、何かと使い勝手が悪いので symbol-sdk v3 で v2 形式のメッセージの暗号化と復号化を実装してみます。

### 確認に使用したもの

- node.js@22.15.0
- symbol-sdk@3.2.3
- デスクトップウォレット@1.0.13

## バージョン 3

まずは v3 の暗号化と復号化を見てみます。

### v3 の暗号化

v3 では暗号化したメッセージをバイナリのままトランザクションにセットします。

```typescript
// メッセージをutf8エンコード
const message = "v3 encrypted message";
const encodeMessage = new TextEncoder().encode(message);
// メッセージを暗号化
const encryptedMessage = aliceAccount
  .messageEncoder()
  .encode(bobPublicAccount.publicKey, encodeMessage);
console.log("encryptedMessage:", utils.uint8ToHex(encryptedMessage));

// 転送トランザクションを作成
const transferDescriptor = new descriptors.TransferTransactionV1Descriptor(
  bobPublicAccount.address,
  [
    new descriptors.UnresolvedMosaicDescriptor(
      new models.UnresolvedMosaicId(nsSymbolXym),
      new models.Amount(1_300000n)
    ),
  ],
  encryptedMessage
);
```

<details><summary>サンプルコード全文</summary>

```typescript
import axios from "axios";
import "dotenv/config";
import { PrivateKey, PublicKey, utils } from "symbol-sdk";
import {
  descriptors,
  generateNamespaceId,
  models,
  SymbolFacade,
  SymbolTransactionFactory,
} from "symbol-sdk/symbol";

// 環境変数
const ENV_NODE_URL = process.env.NODE_URL!;
const ENV_NETWORK = process.env.NETWORK!;
const ENV_ALICE_PRIVATE_KEY = process.env.ALICE_PRIVATE_KEY!;
const ENV_BOB_PUBLIC_KEY = process.env.BOB_PUBLIC_KEY!;

// ファサード生成
const facade = new SymbolFacade(ENV_NETWORK);

// symbol.xymのネームスペースIDを生成
const nsSymbol = generateNamespaceId("symbol");
const nsSymbolXym = generateNamespaceId("xym", nsSymbol);

// アリスアカウント
const alicePrivateKey = new PrivateKey(ENV_ALICE_PRIVATE_KEY);
const aliceAccount = facade.createAccount(alicePrivateKey);
// ボブパブリックアカウント
const bobPublicKey = new PublicKey(ENV_BOB_PUBLIC_KEY);
const bobPublicAccount = facade.createPublicAccount(bobPublicKey);

// メッセージをutf8エンコード
const message = "v3 encrypted message";
const encodeMessage = new TextEncoder().encode(message);
// メッセージを暗号化
const encryptedMessage = aliceAccount
  .messageEncoder()
  .encode(bobPublicAccount.publicKey, encodeMessage);
console.log("encryptedMessage:", utils.uint8ToHex(encryptedMessage));

// 転送トランザクションを作成
const transferDescriptor = new descriptors.TransferTransactionV1Descriptor(
  bobPublicAccount.address,
  [
    new descriptors.UnresolvedMosaicDescriptor(
      new models.UnresolvedMosaicId(nsSymbolXym),
      new models.Amount(1_300000n)
    ),
  ],
  encryptedMessage
);
const transferTx = facade.createTransactionFromTypedDescriptor(
  transferDescriptor,
  aliceAccount.publicKey,
  100,
  60 * 60 * 2,
  0
);

// アリス署名
const signature = aliceAccount.signTransaction(transferTx);
const txPayloadJson = SymbolTransactionFactory.attachSignature(
  transferTx,
  signature
);

// アナウンス
const response = await axios.put(
  new URL("/transactions", ENV_NODE_URL).toString(),
  txPayloadJson,
  {
    headers: { "Content-Type": "application/json" },
  }
);
console.log(response.data);
```

</details>

暗号化メッセージ

```plaintext
encryptedMessage: 01548798BA12E2165EB7FB6DC21EC295AEF9029EB51C0F14FB1580E944EDE9206F1BF084FB9305771712609A4C06297375
```

Rest からトランザクション情報を取得してみると、バイナリのまま格納されていることが分かります。

```json
{
  "meta": {
    "height": "2369349",
    "hash": "F70745D4A08124D07753A571A7B436D4524973ECFEC64435E76692A94B136467",
    "merkleComponentHash": "F70745D4A08124D07753A571A7B436D4524973ECFEC64435E76692A94B136467",
    "index": 0,
    "timestamp": "79880772323",
    "feeMultiplier": 100
  },
  "transaction": {
    "size": 225,
    "signature": "4B46CB7C0F9CD135D84C3E192CD3E133AA557C5E013A1D94848C77524BF00B258CCCB09AB577484F0F2BC5A52A19109104DC955E597C2BC40FB00550B788A901",
    "signerPublicKey": "EBE58AE423DC9549E8B15C5D8E5E4E94F982EA14FB35DF846B24C8ED77A77A8E",
    "version": 1,
    "network": 152,
    "type": 16724,
    "maxFee": "22500",
    "deadline": "79887934484",
    "recipientAddress": "9839D6E1C14864928B58BBD4C56AFC0EE92FF6F1C13E8331",
    "message": "01548798BA12E2165EB7FB6DC21EC295AEF9029EB51C0F14FB1580E944EDE9206F1BF084FB9305771712609A4C06297375",
    "mosaics": [
      {
        "id": "E74B99BA41F4AFEE",
        "amount": "1300000"
      }
    ]
  },
  "id": "68231B67302D72CD310F7B0F"
}
```

デスクトップウォレットは対応していないので復号化できません。

### v3 の復号化

上記のトランザクションを Rest から取得してメッセージを復号化してみます。

:::note info
「アリス秘密鍵/ボブ公開鍵」の組み合わせで復号化していますが、「ボブ秘密鍵/アリス公開鍵」でも復号化可能です。
:::

```typescript
// トランザクションを取得する関数
const getTransaction = async (hash: string) => {
  try {
    const response = await axios.get(
      `${ENV_NODE_URL}/transactions/confirmed/${hash}`
    );
    return response.data;
  } catch (error) {
    console.error("Error fetching transaction:", error);
    throw error;
  }
};

// Restからトランザクションを取得
const txHash =
  "F70745D4A08124D07753A571A7B436D4524973ECFEC64435E76692A94B136467";
const tx = await getTransaction(txHash);
console.log("encryptedMessage:", tx.transaction.message);

// 復号化
const tryDecrypto = aliceAccount
  .messageEncoder()
  .tryDecode(
    bobPublicAccount.publicKey,
    utils.hexToUint8(tx.transaction.message)
  );
console.log("decryptedMessage:", new TextDecoder().decode(tryDecrypto.message));
```

<details><summary>サンプルコード全文</summary>

```typescript
import axios from "axios";
import "dotenv/config";
import { PrivateKey, PublicKey, utils } from "symbol-sdk";
import { SymbolFacade } from "symbol-sdk/symbol";

// 環境変数
const ENV_NODE_URL = process.env.NODE_URL!;
const ENV_NETWORK = process.env.NETWORK!;
const ENV_ALICE_PRIVATE_KEY = process.env.ALICE_PRIVATE_KEY!;
const ENV_BOB_PUBLIC_KEY = process.env.BOB_PUBLIC_KEY!;

// トランザクションを取得する関数
const getTransaction = async (hash: string) => {
  try {
    const response = await axios.get(
      `${ENV_NODE_URL}/transactions/confirmed/${hash}`
    );
    return response.data;
  } catch (error) {
    console.error("Error fetching transaction:", error);
    throw error;
  }
};

// ファサード生成
const facade = new SymbolFacade(ENV_NETWORK);

// アリスアカウント
const alicePrivateKey = new PrivateKey(ENV_ALICE_PRIVATE_KEY);
const aliceAccount = facade.createAccount(alicePrivateKey);
// ボブパブリックアカウント
const bobPublicKey = new PublicKey(ENV_BOB_PUBLIC_KEY);
const bobPublicAccount = facade.createPublicAccount(bobPublicKey);

// Restからトランザクションを取得
const txHash =
  "F70745D4A08124D07753A571A7B436D4524973ECFEC64435E76692A94B136467";
const tx = await getTransaction(txHash);
console.log("encryptedMessage:", tx.transaction.message);

// 復号化
const tryDecrypto = aliceAccount
  .messageEncoder()
  .tryDecode(
    bobPublicAccount.publicKey,
    utils.hexToUint8(tx.transaction.message)
  );
console.log("decryptedMessage:", new TextDecoder().decode(tryDecrypto.message));
```

</details>

実行結果

```plaintext
encryptedMessage: 01548798BA12E2165EB7FB6DC21EC295AEF9029EB51C0F14FB1580E944EDE9206F1BF084FB9305771712609A4C06297375
decryptedMessage : v3 encrypted message
```

## バージョン 2

sdk v3 で v2 方式の暗号化と復号化をしてみましょう。

### v2 の暗号化

v2 はメッセージ暗号化後、文字型にするため 16 進文字列へ変換しています。

```typescript
/**
 * バージョン2の方式でメッセージ文字列を暗号化する。
 * @param account 暗号化に使用するアカウント
 * @param publicKey 相手方の公開鍵
 * @param message 暗号化するメッセージ
 * @returns 文字列として暗号化されたメッセージ
 */
export const encryptoMessageV2 = (
  account: SymbolAccount,
  publicKey: PublicKey,
  message: string
) => {
  // メッセージをutf8エンコード
  const encodeMessage = new TextEncoder().encode(message);
  // メッセージを暗号化
  const encryptedMessage = account
    .messageEncoder()
    .encode(publicKey, encodeMessage);
  // 16進文字列に変換
  const encryptedMessageHex = utils.uint8ToHex(encryptedMessage);
  // メッセージタイプを除去して再付与
  const encryptedMessageDataHex = encryptedMessageHex.substring(2);
  return "\x01" + encryptedMessageDataHex;
};

// メッセージ
const message = "v2 encrypted message";
// メッセージを暗号化
const encryptedMessage = encryptoMessageV2(
  aliceAccount,
  bobPublicAccount.publicKey,
  message
);
console.log("encryptedMessage:", encryptedMessage);
```

暗号化メッセージ

```plaintext
encryptedMessage: 8F8B2569BF6AAFA84F337717AEB630DB9B5ED125CC88F8890070DE3A1950E9DC9CCECB6BDB4463EDB1419B2961651244
```

<details><summary>サンプルコード全文</summary>

```typescript
import axios from "axios";
import "dotenv/config";
import { PrivateKey, PublicKey, utils } from "symbol-sdk";
import {
  descriptors,
  generateNamespaceId,
  models,
  SymbolAccount,
  SymbolFacade,
  SymbolTransactionFactory,
} from "symbol-sdk/symbol";

// 環境変数
const ENV_NODE_URL = process.env.NODE_URL!;
const ENV_NETWORK = process.env.NETWORK!;
const ENV_ALICE_PRIVATE_KEY = process.env.ALICE_PRIVATE_KEY!;
const ENV_BOB_PUBLIC_KEY = process.env.BOB_PUBLIC_KEY!;

/**
 * バージョン2の方式でメッセージ文字列を暗号化する。
 * @param account 暗号化に使用するアカウント
 * @param publicKey 相手方の公開鍵
 * @param message 暗号化するメッセージ
 * @returns 文字列として暗号化されたメッセージ
 */
export const encryptoMessageV2 = (
  account: SymbolAccount,
  publicKey: PublicKey,
  message: string
) => {
  // メッセージをutf8エンコード
  const encodeMessage = new TextEncoder().encode(message);
  // メッセージを暗号化
  const encryptedMessage = account
    .messageEncoder()
    .encode(publicKey, encodeMessage);
  // 16進文字列に変換
  const encryptedMessageHex = utils.uint8ToHex(encryptedMessage);
  // メッセージタイプを除去して再付与
  const encryptedMessageDataHex = encryptedMessageHex.substring(2);
  return "\x01" + encryptedMessageDataHex;
};

// ファサード生成
const facade = new SymbolFacade(ENV_NETWORK);

// symbol.xymのネームスペースIDを生成
const nsSymbol = generateNamespaceId("symbol");
const nsSymbolXym = generateNamespaceId("xym", nsSymbol);

// アリスアカウント
const alicePrivateKey = new PrivateKey(ENV_ALICE_PRIVATE_KEY);
const aliceAccount = facade.createAccount(alicePrivateKey);
// ボブパブリックアカウント
const bobPublicKey = new PublicKey(ENV_BOB_PUBLIC_KEY);
const bobPublicAccount = facade.createPublicAccount(bobPublicKey);

// メッセージ
const message = "v2 encrypted message";
// メッセージを暗号化
const encryptedMessage = encryptoMessageV2(
  aliceAccount,
  bobPublicAccount.publicKey,
  message
);
console.log("encryptedMessage:", encryptedMessage);

// 転送トランザクションを作成
const transferDescriptor = new descriptors.TransferTransactionV1Descriptor(
  bobPublicAccount.address,
  [
    new descriptors.UnresolvedMosaicDescriptor(
      new models.UnresolvedMosaicId(nsSymbolXym),
      new models.Amount(1_300000n)
    ),
  ],
  encryptedMessage
);
const transferTx = facade.createTransactionFromTypedDescriptor(
  transferDescriptor,
  aliceAccount.publicKey,
  100,
  60 * 60 * 2,
  0
);

// アリス署名
const signature = aliceAccount.signTransaction(transferTx);
const txPayloadJson = SymbolTransactionFactory.attachSignature(
  transferTx,
  signature
);

// アナウンス
const response = await axios.put(
  new URL("/transactions", ENV_NODE_URL).toString(),
  txPayloadJson,
  {
    headers: { "Content-Type": "application/json" },
  }
);
console.log(response.data);
```

</details>

Rest からトランザクションを取得してみると、バイナリを 16 進文字列にしているので、v3 より長くなっていることが分かります。

```json
{
  "meta": {
    "height": "2369547",
    "hash": "839717AF5185949783E3C32F7D2B662CBE732E6FAB1F96A34C825FB2593B8053",
    "merkleComponentHash": "839717AF5185949783E3C32F7D2B662CBE732E6FAB1F96A34C825FB2593B8053",
    "index": 0,
    "timestamp": "79887063476",
    "feeMultiplier": 100
  },
  "transaction": {
    "size": 273,
    "signature": "6D70F7A771C6F18E15EAAE872882E75830DDA0014E8EDBD919FCCB0BB9ED5313CC9CE15B2E987DA0794D71B68B5D0CA6EF4594DC2EDB31E59A273A84581E0B07",
    "signerPublicKey": "EBE58AE423DC9549E8B15C5D8E5E4E94F982EA14FB35DF846B24C8ED77A77A8E",
    "version": 1,
    "network": 152,
    "type": 16724,
    "maxFee": "27300",
    "deadline": "79894244390",
    "recipientAddress": "9839D6E1C14864928B58BBD4C56AFC0EE92FF6F1C13E8331",
    "message": "01384638423235363942463641414641383446333337373137414542363330444239423545443132354343383846383839303037304445334131393530453944433943434543423642444234343633454442313431394232393631363531323434",
    "mosaics": [
      {
        "id": "E74B99BA41F4AFEE",
        "amount": "1300000"
      }
    ]
  },
  "id": "682333FA302D72CD310F7D1F"
}
```

デスクトップウォレットで復号化可能です。

### v2 の復号化

上記のトランザクションを Rest から取得してメッセージを復号化してみます。

```typescript
/**
 * バージョン2で暗号化されたメッセージ文字列を復号化する。
 * @param account 復号化に使用するアカウント
 * @param publicKey 相手方の公開鍵
 * @param hexMessage 暗号化されたメッセージの16進文字列（メッセージタイプのプレフィックス付き）
 * @returns 文字列として復号化されたメッセージ
 */
export const decryptoMessageV2 = (
  account: SymbolAccount,
  publicKey: PublicKey,
  hexMessage: string
) => {
  // メッセージタイプを確認
  const messageType = hexMessage.substring(0, 2);
  if (messageType !== "01") throw new Error("Invalid message type");
  // 1バイト目はメッセージタイプなので、2バイト目からデコードする
  const messageData = hexMessage.substring(2);
  const decodeMessageHex = utils.hexToUint8(messageData);
  const decryptedMessageHex = new TextDecoder().decode(decodeMessageHex);
  // デコードされたメッセージを復号化
  try {
    const tryDecode = account
      .messageEncoder()
      .tryDecode(publicKey, utils.hexToUint8("01" + decryptedMessageHex));
    return { isDecoded: tryDecode.isDecoded, message: tryDecode.message };
  } catch (error) {
    console.error("Error decoding message:", error);
    throw error;
  }
};

// トランザクションを取得する関数
const getTransaction = async (hash: string) => {
  try {
    const response = await axios.get(
      `${ENV_NODE_URL}/transactions/confirmed/${hash}`
    );
    return response.data;
  } catch (error) {
    console.error("Error fetching transaction:", error);
    throw error;
  }
};

// Restからトランザクションを取得
const txHash =
  "839717AF5185949783E3C32F7D2B662CBE732E6FAB1F96A34C825FB2593B8053";
const tx = await getTransaction(txHash);
console.log("encryptedMessage:", tx.transaction.message);

// 復号化
const tryDecrypto = decryptoMessageV2(
  aliceAccount,
  bobPublicAccount.publicKey,
  tx.transaction.message
);
console.log("decryptedMessage:", new TextDecoder().decode(tryDecrypto.message));
```

実行結果

```plaintext
encryptedMessage: 01384638423235363942463641414641383446333337373137414542363330444239423545443132354343383846383839303037304445334131393530453944433943434543423642444234343633454442313431394232393631363531323434
decryptedMessage: v2 encrypted message
```

<details><summary>サンプルコード全文</summary>

```typescript
import axios from "axios";
import "dotenv/config";
import { PrivateKey, PublicKey, utils } from "symbol-sdk";
import { SymbolAccount, SymbolFacade } from "symbol-sdk/symbol";

// 環境変数
const ENV_NODE_URL = process.env.NODE_URL!;
const ENV_NETWORK = process.env.NETWORK!;
const ENV_ALICE_PRIVATE_KEY = process.env.ALICE_PRIVATE_KEY!;
const ENV_BOB_PUBLIC_KEY = process.env.BOB_PUBLIC_KEY!;

/**
 * バージョン2で暗号化されたメッセージ文字列を復号化する。
 * @param account 復号化に使用するアカウント
 * @param publicKey 相手方の公開鍵
 * @param hexMessage 暗号化されたメッセージの16進文字列（メッセージタイプのプレフィックス付き）
 * @returns 文字列として復号化されたメッセージ
 */
export const decryptoMessageV2 = (
  account: SymbolAccount,
  publicKey: PublicKey,
  hexMessage: string
) => {
  // メッセージタイプを確認
  const messageType = hexMessage.substring(0, 2);
  if (messageType !== "01") throw new Error("Invalid message type");
  // 1バイト目はメッセージタイプなので、2バイト目からデコードする
  const messageData = hexMessage.substring(2);
  const decodeMessageHex = utils.hexToUint8(messageData);
  const decryptedMessageHex = new TextDecoder().decode(decodeMessageHex);
  // デコードされたメッセージを復号化
  try {
    const tryDecode = account
      .messageEncoder()
      .tryDecode(publicKey, utils.hexToUint8("01" + decryptedMessageHex));
    return { isDecoded: tryDecode.isDecoded, message: tryDecode.message };
  } catch (error) {
    console.error("Error decoding message:", error);
    throw error;
  }
};

// トランザクションを取得する関数
const getTransaction = async (hash: string) => {
  try {
    const response = await axios.get(
      `${ENV_NODE_URL}/transactions/confirmed/${hash}`
    );
    return response.data;
  } catch (error) {
    console.error("Error fetching transaction:", error);
    throw error;
  }
};

// ファサード生成
const facade = new SymbolFacade(ENV_NETWORK);

// アリスアカウント
const alicePrivateKey = new PrivateKey(ENV_ALICE_PRIVATE_KEY);
const aliceAccount = facade.createAccount(alicePrivateKey);
// ボブパブリックアカウント
const bobPublicKey = new PublicKey(ENV_BOB_PUBLIC_KEY);
const bobPublicAccount = facade.createPublicAccount(bobPublicKey);

// Restからトランザクションを取得
const txHash =
  "839717AF5185949783E3C32F7D2B662CBE732E6FAB1F96A34C825FB2593B8053";
const tx = await getTransaction(txHash);
console.log("encryptedMessage:", tx.transaction.message);

// 復号化
const tryDecrypto = decryptoMessageV2(
  aliceAccount,
  bobPublicAccount.publicKey,
  tx.transaction.message
);
console.log("decryptedMessage:", new TextDecoder().decode(tryDecrypto.message));
```

</details>

## おわりに

公式ウォレットやエクスプローラが v2 ベースで作成されているので、v3 ベースにバージョンアップされるまでは v3 でも v2 と同じ方式で実装するほうが良いかと思います。