Web3.Storageと暗号化技術を組み合わせて、ファイルを安全に管理する

この記事は、[株式会社うるる（ULURU） Advent Calendar 2023](https://qiita.com/advent-calendar/2023/uluru)の18日目の記事です。

# はじめに
最近、[Web3.Storage](https://web3.storage/)というサービスに触れる機会があったので、使い方やできることをまとめつつ、暗号化技術を組み合わせてファイルを安全に管理する方法を模索します。

# この記事で登場するコード群
以下Githubレポジトリにて公開しています。

https://github.com/tatsukoni-pra/Web3Storage-Secure-Upload-Demo/tree/main?tab=readme-ov-file

# 概要
本記事で登場するWeb3.Storage, Filecoinストレージについて説明します。

:::note info
詳細まで踏み込むと長くなってしまうので、概要の説明に留めています。
より詳しくは、添付している公式のリンクなどを参照ください。
:::

## Web3.Storage とは

https://web3.storage/

https://web3.storage/docs/

Web3.Storageとは、Filecoinストレージへのアクセスを容易 & シンプルにしてくれるインタフェースサービスのことです。
開発者が裏側の仕組みを意識することなく、Filecoinストレージに対してファイルのアップロード & ダウンロードなどを行うことができます。

## Filecoinストレージとは

https://filecoin.io/

https://internet.watch.impress.co.jp/docs/review/1285154.html

Filecoinとは、近年登場した分散型ストレージのことです。
大きな特徴として、全世界に存在するデジタルデバイスの空き容量をストレージとして利用する ということがあります。

世界中の空きデバイスにファイルを分散させる仕組みは、[IPFS](https://ipfs-book.decentralized-web.jp/what_is_ipfs/)というP2P通信のプロトコルを用いて実現されています。
Filecoinでは、自分の持つ空きデバイス容量の一部を貸し出し、報酬を得るような経済モデルを構築したことで、空きストレージが共有され続けるような仕組みになっています。

:::note info
イメージとしては、Airbnbのファイル版 といった感じです。
:::

# Web3.Storageを使ってみる
最近、[大きなアップデート](https://blog.web3.storage/posts/the-data-layer-is-here-with-the-new-web3-storage)が入ったそうで、過去に自分が触った時点と仕様が結構変わっていました・・・:cry:
本記事ではアップデート後のWeb3.Storageで動かしてみたいので、まずはアカウント設定から、ファイルのアップロード・ダウンロードなど基本操作を試してみます。

## アカウント & スペース作成
[公式サイト](https://web3.storage/)の案内に沿って、アカウント & スペースを作成します。
UI上は、スペースに対して、ファイルをアップロード & 管理するような動きになります。
また、各スペースには`DID`という一意なIDが割り振られており、プログラムからアクセスする際などはこの値を使うことになります。

![スクリーンショット-2023-12-17-14-03-05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/351743/2456b213-ef6c-7af4-2291-8f18504a94ae.png)

## ファイルをアップロードしてみる
[JavaScript クライアント](https://web3.storage/docs/w3up-client/)が提供されているので、これを使ってみます。
まずは、プログラムからアクセス可能にするための、認証用コードが必要になります。

```javascript
import { create } from '@web3-storage/w3up-client';
import dotenv from 'dotenv';
dotenv.config();

const client = await create();
const account = await client.login(process.env.LOGIN_ADDRESS);
const did = process.env.DID;
await account.provision(did);
```

GUI上で、メールアドレスにてログイン & 対象スペースを選択 という動きをプログラムで実施しているイメージです。
`DID`は、スペース作成時に表示される値を使いましょう。
試してないですが、毎回ログインするのではなく、「代表者の権限を委任する」ということもできるみたいです。[参考](https://web3.storage/docs/how-to/upload/#bring-your-own-delegations)

認証が通過したら、ファイルをアップロードするコードを記述します。

```javascript
import { create } from '@web3-storage/w3up-client';
import { filesFromPaths } from 'files-from-path';
import dotenv from 'dotenv';
dotenv.config();

const client = await create();
// ... 認証コード
const file = await filesFromPaths(['sample_images/sample-image.png']);
const cid = await client.uploadFile(file[0]);
console.log('cid', cid);
```

コードの全体像はこんな感じ。
<details><summary><strong>サンプルコード</strong></summary>

```javascript
import { create } from '@web3-storage/w3up-client';
import { filesFromPaths } from 'files-from-path';
import dotenv from 'dotenv';
dotenv.config();

async function upload()
{
  const client = await getClient();
  const file = await filesFromPaths(['sample_images/sample-image.png']);
  const cid = await client.uploadFile(file[0]);
  console.log('cid', cid);
}

async function getClient()
{
  const client = await create();
  const account = await client.login(process.env.LOGIN_ADDRESS);
  const did = process.env.DID;
  await account.provision(did);
  await client.setCurrentSpace(did);
  return client;
}

await upload()
  .catch(console.error)
  .finally(() => process.exit())
```
</details>

動かしてみると、Web3.Storageのスペース上にファイルがアップロードされていることが確認できるかと思います。

```console
❯❯❯ make upload
node js/upload.js
cid CID(bafkreiciko4vlpseyuzwhz5h3t5mv5t4ejwrri35vkqpdvtgb654ux7pei)
```

![スクリーンショット-2023-12-17-14-28-36.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/351743/3632694a-34eb-ed82-afd1-bec55e02aa20.png)

![スクリーンショット-2023-12-17-14-28-51.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/351743/15ffbeb9-56d5-093c-a4ae-9a2ea8f9f4af.png)

CIDというのは、アップロードされたファイルのコンテンツ識別子のことです。

## ファイルをブラウザ上で表示 & ダウンロードしてみる
URL箇所に表示されている、IPFSゲートウェイURLを叩くことで、ブラウザ上でファイルを表示することができます。

![スクリーンショット 2023-12-17 14.37.59.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/351743/8d6583ae-a0ef-a2f2-2ee0-311f88244733.png)

ダウンロードについては、JSクライアントでは今時点で実装が提供されてなさそうだったので、[curlコマンド](https://web3.storage/docs/how-to/retrieve/#using-curl)などで実施することになりそうです。

# Web3.Storageと暗号化技術を組み合わせる
ここからが本題です。
Web3.Storageと暗号化技術を組み合わせることで、安全にファイルを保存する方法を模索してみます。

[公式ドキュメント](https://web3.storage/docs/how-to/upload/)でも注意喚起されていますが、アップロードしたファイルについては、CID（コンテンツ識別子）を知っている人であれば誰でもアクセス可能です。
そのため、例えば機密情報を保存したい場合、閲覧可能者をコントロールする工夫が必要になります。

> Public Data 🌎
All data uploaded to w3up is available to anyone who requests it using the correct CID. Do not store any private or sensitive information in an unencrypted form using w3up.

そこで今回は、[PGP（Pretty Good Privacy）](https://ja.wikipedia.org/wiki/Pretty_Good_Privacy)の仕組みを用いて、ファイルを暗号化した状態でWeb3.Storageにアップロード & 秘密鍵を保有しているユーザのみファイルを復元して閲覧可能 という動きを実装してみます。

## 鍵の準備
まずは、暗号化に使う公開鍵 & 秘密鍵を作成 & importします。
鍵自体は、gpgコマンドで作成しても、JavaScriptの`openpgp`パケージで作成しても構いません。
JavaScriptで実施するなら以下の感じです。

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

```javascript
import * as openpgp from 'openpgp';
import fs from 'fs';

const { privateKey, publicKey } = await openpgp.generateKey({
  type: 'ecc',
  curve: 'curve25519',
  userIDs: [{
    name: process.env.GPG_USER_NAME,
    email: process.env.GPG_USER_EMAIL
  }],
  format: 'armored'
});

console.log(privateKey);
console.log(publicKey);
fs.writeFileSync('private-key.key', privateKey);
```
</details>

鍵作成後は、公開鍵情報は`public_keys/publicKey1.asc`に記載します。
秘密鍵情報は、復号操作を可能にしたいPC上で`gpg --import`コマンドを実施してimportしましょう。

## ファイルを暗号化してアップロード
ファイルの暗号化にも、JavaScriptの`openpgp`パケージを使います。
暗号化箇所のコードは以下です。

```javascript
import openpgp from 'openpgp';
import fs from 'fs';
import dotenv from 'dotenv';
dotenv.config();

// Public keys
const publicKeysArmored = [
  fs.readFileSync(process.env.PUBLIC_KEY1_PATH, 'utf8'),
  fs.readFileSync(process.env.PUBLIC_KEY2_PATH, 'utf8'),
];
const publicKeys = await Promise.all(
  publicKeysArmored.map((armoredKey) => openpgp.readKey({ armoredKey }))
);

// File data
const fileData = fs.readFileSync('sample_images/sample-image.png');
const fileMessage = await openpgp.createMessage({ binary: fileData });

const encrypted = await openpgp.encrypt({
  message: fileMessage,
  encryptionKeys: publicKeys,
  format: "binary",
});
```

`publicKeysArmored`箇所で複数の公開鍵を指定することで、他のユーザが暗号化したファイルについても、自分の保有する秘密鍵を用いて復号することができます。

:::note
このようにすることで、「<strong>同一グループ間でのみファイルの閲覧可能</strong>」といった、グループ認証の機構が実現可能になります。
:::

コードの全体像は以下のようになります。

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

```javascript
import { create } from '@web3-storage/w3up-client';
import { filesFromPaths } from 'files-from-path';
import openpgp from 'openpgp';
import fs from 'fs';
import dotenv from 'dotenv';
dotenv.config();

async function upload()
{
  const client = await getClient();

  // encrypt file
  const encrypted = await encryptFile();
  const tempFilePath = 'tmp-sample-image.png';
  fs.writeFileSync(tempFilePath, new Uint8Array(encrypted));

  // upload encrypted file
  const encryptedFile = await filesFromPaths([tempFilePath]);
  const cid = await client.uploadFile(encryptedFile[0]);
  console.log('cid', cid);

  // clean up tmp file
  fs.unlinkSync(tempFilePath);
}

async function getClient()
{
  const client = await create();
  const account = await client.login(process.env.LOGIN_ADDRESS);
  const did = process.env.DID;
  await account.provision(did);
  await client.setCurrentSpace(did);
  return client;
}

async function encryptFile() {
  // Public keys
  const publicKeysArmored = [
    fs.readFileSync(process.env.PUBLIC_KEY1_PATH, 'utf8'),
    fs.readFileSync(process.env.PUBLIC_KEY2_PATH, 'utf8'),
  ];
  const publicKeys = await Promise.all(
    publicKeysArmored.map((armoredKey) => openpgp.readKey({ armoredKey }))
  );

  // File data
  const fileData = fs.readFileSync('sample_images/sample-image.png');
  const fileMessage = await openpgp.createMessage({ binary: fileData });

  const encrypted = await openpgp.encrypt({
    message: fileMessage,
    encryptionKeys: publicKeys,
    format: "binary",
  });

  return encrypted;
}

await upload()
  .catch(console.error)
  .finally(() => process.exit())
```
</details>

## 動作デモ
上記コードを実際に動かしてみます。
まずは、ファイルを暗号化した上でWeb3.Storageにアップロードします。

```console
❯❯❯ make encrypt-upload
node js/encrypt-upload.js
cid CID(bafkreiajim6gql4tziycfsn3m2c2rs4pkxifndj2re3jy5awaaxdmokkxi)
```

![スクリーンショット-2023-12-17-15-12-11.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/351743/31e9b9d4-4e2b-00ae-0667-f7b4576c22bf.png)

ファイルが暗号化されているため、そのままの状態ではブラウザで閲覧できない & ダウンロードしても開けないことを確認しましょう。

![スクリーンショット 2023-12-17 15.13.26.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/351743/e505c2eb-19e6-42a9-d380-7ccb1d30eee4.png)

```console
❯❯❯ make download URL=https://bafkreiajim6gql4tziycfsn3m2c2rs4pkxifndj2re3jy5awaaxdmokkxi.ipfs.w3s.link DOWNLOAD_FILE_NAME=encripted-sample-image.png
sh cmd/download.sh https://bafkreiajim6gql4tziycfsn3m2c2rs4pkxifndj2re3jy5awaaxdmokkxi.ipfs.w3s.link encripted-sample-image.png
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100  3242  100  3242    0     0   1582      0  0:00:02  0:00:02 --:--:--  1586
```

![スクリーンショット 2023-12-17 15.15.10.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/351743/df607168-9238-3cbe-9638-21459f1212ef.png)

次に、秘密鍵をimportしたPC上で、ファイルを復元してみます。
復元は、`gpg --decrypt`コマンドを実施して行います。

```console
❯❯❯ make decrypt ENCRIPTED_FILE_NAME=encripted-sample-image.png DECRYPTED_FILE_NAME=decripted-sample-image.png
sh cmd/decrypt.sh encripted-sample-image.png decripted-sample-image.png
gpg: ECDH鍵, ID 89E54450FC5F9CD4で暗号化されました
gpg: cv25519鍵, ID DAF2F738BAAD9868, 日付2023-12-16に暗号化されました
```

すると、暗号化されたファイルが復号され、PC上でアクセス可能になりました！

![スクリーンショット 2023-12-17 15.18.15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/351743/8937dd0d-4b15-64b5-0fa6-31ed93a146c8.png)

# まとめ
Web3.Storageと暗号化技術を組み合わせることで、ファイルへのアクセスをコントロールし、安全に管理する方法を模索してみました。

ただし、より一般化するためには、`gpg`コマンドを用いて復号する箇所などを抽象化し、画像アップロード・ダウンロード操作の過程でよりシームレスに実施できるようにする必要がありそうです。