暗号化ストレージ (Stronghold) の導入と利用

Stronghold プラグインで API トークンを暗号化ファイルに保存・読み出す。with_argon2() でパスワードから鍵を作る仕組み、save() 忘れで消える落とし穴、Store や OS の資格情報との使い分けも示す。

データ保存 対象: Tauri 2.x 更新日: 読了目安: 約8分 db-005
目次
  1. 前提条件
  2. パスワードから鍵を作る仕組み
  3. 1. フロントエンドから実装する (TypeScript)
  4. 2. バックエンドから実装する (Rust)
  5. 動作確認
  6. よくあるエラーと対処法
  7. OS ごとの違いと注意点
  8. 関連レシピ

API トークンや秘密鍵のように、ファイルを見られても読まれたくない値は、Stronghold プラグインで暗号化したファイル(スナップショット)に保存します。JS から Stronghold.load() にパスワードを渡して開き、Rust 側に登録した関数がそのパスワードから暗号化の鍵を作ります。平文の JSON に書く Store プラグイン と違い、守りの強さは「パスワードをどこから持ってくるか」で決まるので、その選び方も説明します。

前提条件

npm run tauri add stronghold

stronghold:default で、開く・クライアントの作成と読み込み・値の保存と取得・ファイルへの書き込みができます。値の削除(store.remove())には stronghold:allow-remove-store-record、unload() には stronghold:allow-destroy を別に足します。

{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "default",
  "description": "Capability for the main window",
  "windows": ["main"],
  "permissions": [
    "core:default",
    "stronghold:default",
    "stronghold:allow-remove-store-record"
  ]
}

公式ドキュメントは、上流の不具合への対策として src-tauri/Cargo.toml に次を足すよう勧めています。

[profile.dev.package.scrypt]
opt-level = 3

パスワードから鍵を作る仕組み

Stronghold の鍵は 32 バイトです。Builder::with_argon2(salt_path) で登録すると、パスワードとソルト(ランダムな 32 バイト)から Argon2 で鍵を計算します。ソルトは最初に Stronghold.load() を呼んだときに作られ、salt_path のファイルに保存されます。同じパスワードと同じソルトからは同じ鍵ができるので、次回も開けます。ソルトは秘密ではありませんが、消えると正しいパスワードでも開けなくなります。

Builder::new(|password| ...) で自前の関数を渡すこともできます。その場合は、ちょうど 32 バイトを返す、同じパスワードには毎回同じ値を返す(呼ぶたびに乱数のソルトを作らない)、SHA-256 のような速いハッシュをそのまま使わない、の 3 点を守ります。

守れる範囲はパスワードの出どころで決まります。

パスワードの出どころ守れる範囲向く場面
利用者が起動のたびに入力するファイルとアプリを持ち出されても開けないパスワード管理、秘密鍵
アプリに埋め込んだ固定の文字列ファイルだけを見られた場合。アプリを解析されれば開ける平文で置かないための最低限
乱数を OS の資格情報ストアに保存OS のログインで守られる入力なしで開きたい

入力なしで済ませたいなら、値そのものを OS の資格情報ストア(Windows の資格情報マネージャー、macOS のキーチェーン、Linux の Secret Service)に置く手もあります。公式プラグインには無いので、Rust の keyring クレートなどを使います。トークンが 1〜2 個なら資格情報ストア、マスターパスワードで多くの秘密を守るなら Stronghold、秘密でない設定は Store と分けます。

1. フロントエンドから実装する (TypeScript)

保存先は Stronghold.load() に絶対パスで渡します(相対パスはデータフォルダーを基準にしません)。開くのはアプリの実行中に 1 回にします。同じパスで load() し直すと、ファイルから読み直した状態に置き換わり、save() していない変更は失われます。

クライアントは「初回だけ作る」ようにします。loadClient() の失敗をすべて createClient() に回すと、読み込み済みのクライアントをもう一度読もうとした失敗でも空のクライアントに置き換わり、次の save() で中身が消えます。

import { Client, Store, Stronghold } from '@tauri-apps/plugin-stronghold';
import { appLocalDataDir, join } from '@tauri-apps/api/path';

type Vault = { stronghold: Stronghold; store: Store };
let opened: Promise<Vault> | null = null;

async function unlock(password: string): Promise<Vault> {
  const path = await join(await appLocalDataDir(), 'vault.hold');
  const stronghold = await Stronghold.load(path, password); // パスワードが違えばここで失敗
  let client: Client;
  try {
    client = await stronghold.loadClient('main');
  } catch (e) {
    if (!String(e).includes('no data present')) throw e; // まだ保存が無い初回だけ作る
    client = await stronghold.createClient('main');
  }
  return { stronghold, store: client.getStore() };
}

export function openVault(password: string): Promise<Vault> {
  opened ??= unlock(password).catch((e) => {
    opened = null; // 失敗したら入力し直せるようにする
    throw e;
  });
  return opened;
}

値はバイト列(number[])で出し入れします。save() を呼ぶまでファイルには書かれず、自動保存も終了時の保存もありません。

// (続き)
const enc = new TextEncoder();
const dec = new TextDecoder();

function unlocked(): Promise<Vault> {
  return opened ?? Promise.reject(new Error('vault is locked'));
}

export async function saveSecret(key: string, value: string) {
  const { stronghold, store } = await unlocked();
  await store.insert(key, Array.from(enc.encode(value)));
  await stronghold.save(); // これでファイルに書かれる
}

export async function readSecret(key: string): Promise<string | null> {
  const { store } = await unlocked();
  const data = await store.get(key); // 無ければ null
  return data ? dec.decode(data) : null;
}

export async function deleteSecret(key: string) {
  const { stronghold, store } = await unlocked();
  await store.remove(key); // stronghold:allow-remove-store-record が必要
  await stronghold.save();
}

client.getVault() で得る Vault は、書き込んだ値を JS から読み戻せません(鍵の生成や署名の手順に使うものです)。トークンのように後で読む値は、ここで使った getStore() の Store に入れます。

2. バックエンドから実装する (Rust)

ソルトの保存先を決めるのに app.path() が要るので、setup の中でプラグインを登録します。ソルトは Stronghold.load() のときに書かれますが、フォルダーは自動では作られないので、ここで作っておきます。

use tauri::Manager;

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .setup(|app| {
            let dir = app.path().app_local_data_dir()?;
            std::fs::create_dir_all(&dir)?; // 無いとソルトを書けずに失敗する
            let salt_path = dir.join("salt.txt");
            app.handle()
                .plugin(tauri_plugin_stronghold::Builder::with_argon2(&salt_path).build())?;
            Ok(())
        })
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

JS で開いたスナップショットを Rust のコマンドから取り出す API はありません。Rust の処理でトークンを使うなら、JS で読んで invoke() の引数で渡します。

動作確認

// (続き)
await openVault('correct horse battery staple');
await saveSecret('api-token', 'sk-test-123');
console.log(await readSecret('api-token')); // sk-test-123

npm run tauri dev で実行すると sk-test-123 が表示され、appLocalDataDir()(Windows なら %LOCALAPPDATA%\<identifier>)に vault.hold と salt.txt ができます。vault.hold をエディターで開いても、トークンの文字列は見つかりません。再起動して readSecret() だけを呼んでも同じ値が返り、別のパスワードで開くと openVault() が失敗します。

よくあるエラーと対処法

  • 「inner error occurred(…)」で Stronghold.load() が失敗する: パスワードが違うか、salt.txt が作り直されて鍵が変わっています。
  • 「illegal non-contiguous size」: Builder::new() に渡した関数が 32 バイト以外を返しています。
  • Rust 側が「Failed to write salt for Stronghold」で panic する: salt.txt を置くフォルダーがありません。create_dir_all() で作ります。
  • 「stronghold.remove_store_record not allowed. Permissions associated with this command: stronghold:allow-remove-store-record」: 削除の権限は stronghold:default に入っていません(リリースビルドでは「Command plugin:stronghold|remove_store_record not allowed by ACL」)。
  • 再起動すると値が消えている: save() を呼んでいないか、createClient() で空のクライアントに置き換えています。

OS ごとの違いと注意点

  • 対応 OS: 公式ドキュメントでは Windows / macOS / Linux / Android / iOS のすべてに対応しています。
  • パスワードを忘れたら戻せない: 復旧の手段は無いので、「初期化」は vault.hold と salt.txt を両方消して作り直す流れにします。バックアップや移行でも 2 つを必ず一緒に扱います。
  • 開いた後は JS から読める: WebView に不正なスクリプトが入り込めば読まれます。外部のスクリプトを読み込まないなど、WebView 側の対策は別に必要です。
  • 平文に書き出さない: 取り出した値をログや CSV のエクスポートに含めると、暗号化した意味がなくなります。
  • 保存したトークンを API に送る方法は HTTP ヘッダーをカスタマイズして送る を参照してください。

関連レシピ

参考リンク(公式ドキュメント)

Web Ninja

この記事を書いた人

Web Ninja ウェブエンジニア (Web Engineer)

会社員ネットワークエンジニアから独立してかれこれ 25 年以上 Web エンジニアとして活動中。普段は JavaScript と Node.js を自在に操り、時には C++ や Perl といった古流の技も嗜みます。近年は Tauri × Rust という新たな武器を手に、デスクトップアプリ開発の最前線を駆け抜けています。「作りたい」を「作れる」に変えるための、実践的な「技」をお届けします。

お問い合わせ: tauri.ninja@gmail.com

内容の誤り・動かないコードを報告する