1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Rustで機密情報を安全に扱う:Keyring入門と v4 ので快適な開発体験

1
Last updated at Posted at 2025-12-25

この記事で扱うこと

※以降のサンプルコードは macOS を前提にしています(他OSでも基本APIは同じですが、ストア実装は変わります)。

  • Keyring(Rust)の概要:OS のセキュアストレージを横断して機密情報(secret)を保存・取得できる
  • v3 の最小サンプル(Entry::new / set_password / get_password / delete_credential
  • 開発時のペインポイント:OS の Keychain に依存するとテストが面倒になりがち
  • v3 の回避策
    • 回避策(1): mock ストアに差し替えて、ネイティブストアに触らずテストする
    • 回避策(2): (応用)クライアント提供ストアを自作して“簡易なファイル永続化”をする
  • v4 の設計変更と改善点:use_sample_store によるストア差し替え
    • 開発・テスト時だけ sample store(サンプルストア)を使う
    • 必要なら backing-file でファイル永続化もできる

Keyringとは

Keyring は、パスワードやAPIトークンなどの「機密情報(secret)」を、OS などが提供する credential store(macOSなら Keychain)へ安全に保存/取得するための Rust 製エコシステムです。

※本記事では v3 と v4 を扱います。v4 は執筆時点でまだ正式リリース前(pre-release)ですが、設計の方向性と開発/テスト体験の改善点を紹介します。

v3でも v4 系でも、アプリ側が使う中心概念は Entry です。アプリケーション側は「サービス名 + ユーザー名」で識別される Entry を使って、以下の操作を行えます。

  • 保存: set_password / set_secret
  • 取得: get_password / get_secret
  • 削除: delete_credential

一方で v4 系では、API本体が keyring-core として分離され、keyring はストア接続のヘルパやCLI/サンプルを担う構成になっています。

参考:


v3 の最小サンプル(macOS: Keychain を想定)

v3.6.3 の README では、以下のように Entry を使って機密情報を保存・取得・削除できます。

use keyring::{Entry, Result};

fn main() -> Result<()> {
    let entry = Entry::new("my-service", "my-name")?;
    entry.set_password("topS3cr3tP4$$w0rd")?;
    let password = entry.get_password()?;
    println!("My password is '{}'", password);
    entry.delete_credential()?;
    Ok(())
}

Cargo.toml は、利用する OS ストアに応じて feature を選びます(例: macOS + Windows + Linux Secret Service)。

keyring = { version = "3", features = ["apple-native", "windows-native", "sync-secret-service"] }

※補足: v3 は keyring 1 crate の中に複数のストア実装が同居しており、どれを有効にするかを feature で選ぶ形です。


開発時のペインポイント:OSのセキュアストレージに依存するとテストが面倒

OS のセキュアストレージに入れるのはセキュリティ面では有効ですが、開発やテストでは別の問題が出ます。

  • 開発中/テスト中に Keychain 側の許可やロック解除が絡む(環境・設定次第でダイアログが出たり、権限が必要になったり)
  • CI のような非対話環境では「OS の UI に依存すること自体」が難しい
  • テストデータが OS ストアに残ると、再現性や後始末が面倒

v3の回避策(1):mockストアに差し替えてテストする(ネイティブストアに触らない)

keyring v3 にはテスト用の mock ストアが同梱されています。

  • OS の Keychain/Secret Service 等に触らないので、CI やユニットテストで扱いやすい
  • 永続化はしない(プロセス内のみ)
  • 任意のエラーを「次の呼び出しだけ」注入できる

最小例:default credential builder を mock に差し替えてから Entry::new する

use keyring::{mock, set_default_credential_builder, Entry, Result};

fn main() -> Result<()> {
    // Entry を作る前に mock をセット
    set_default_credential_builder(mock::default_credential_builder());

    let entry = Entry::new("my-service", "my-name")?;
    entry.set_password("topS3cr3tP4$$w0rd")?;
    assert_eq!(entry.get_password()?, "topS3cr3tP4$$w0rd");

    entry.delete_credential()?;
    Ok(())
}

エラー注入の例:MockCredential に downcast して set_error を呼ぶ

use keyring::{mock, set_default_credential_builder, Entry, Error, Result};
use keyring::mock::MockCredential;

fn main() -> Result<()> {
    set_default_credential_builder(mock::default_credential_builder());

    let entry = Entry::new("service", "user")?;
    let mock: &MockCredential = entry.get_credential().downcast_ref().unwrap();

    mock.set_error(Error::Invalid(
        "mock error".to_string(),
        "takes precedence".to_string(),
    ));

    // 1回目はエラー
    entry.set_password("test").expect_err("error will override");
    // 2回目は通常動作(エラーはクリアされる)
    entry.set_password("test")?;

    Ok(())
}

v3の回避策(2):クライアント提供ストアで“ファイル永続化”する(サンプル実装)

mock は「その場のテスト」をとても楽にしてくれますが、永続化はしません。

開発中に次のような要件が出ることがあります。

  • 「手元の開発では OS の Keychain に入れたくない(ダイアログが出る/権限が面倒)」
  • でも「プロセスを再起動しても値は残っていてほしい」(= 簡易永続化)

v3で、この要件にこたえるとすると credential store を自作することになります。

以下は、set_default_credential_builder で差し替え可能な形で、1ファイルにJSONとして保存するだけの最小ストア実装例です。

Cargo.toml

このサンプルでは JSON の読み書きに serdeserde_json を使います。

[dependencies]
keyring = { version = "3.6.3" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"

main.rs(FileCredentialBuilder / FileCredential)

use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Mutex;

use keyring::credential::{
    Credential, CredentialApi, CredentialBuilder, CredentialBuilderApi, CredentialPersistence,
};
use keyring::{set_default_credential_builder, Error, Result};

use serde::{Deserialize, Serialize};

#[derive(Default, Debug, Serialize, Deserialize)]
struct FileDb {
    // key = "{service}\n{user}\n{target}" のように単純連結して管理
    entries: HashMap<String, Vec<u8>>,
}

fn make_key(target: Option<&str>, service: &str, user: &str) -> String {
    let t = target.unwrap_or("");
    format!("{service}\n{user}\n{t}")
}

fn load_db(path: &Path) -> Result<FileDb> {
    if !path.exists() {
        return Ok(FileDb::default());
    }
    let bytes = fs::read(path).map_err(|e| Error::PlatformFailure(Box::new(e)))?;
    serde_json::from_slice(&bytes).map_err(|e| Error::PlatformFailure(Box::new(e)))
}

fn save_db(path: &Path, db: &FileDb) -> Result<()> {
    let bytes = serde_json::to_vec_pretty(db).map_err(|e| Error::PlatformFailure(Box::new(e)))?;
    fs::write(path, bytes).map_err(|e| Error::PlatformFailure(Box::new(e)))
}

#[derive(Debug)]
struct FileCredential {
    path: PathBuf,
    key: String,
    db: Mutex<FileDb>,
}

impl FileCredential {
    fn new(path: PathBuf, target: Option<&str>, service: &str, user: &str) -> Result<Self> {
        let db = load_db(&path)?;
        Ok(Self {
            path,
            key: make_key(target, service, user),
            db: Mutex::new(db),
        })
    }

    fn with_db_mut<T>(&self, f: impl FnOnce(&mut FileDb) -> Result<T>) -> Result<T> {
        let mut guard = self
            .db
            .lock()
            .map_err(|_| Error::PlatformFailure(Box::new(std::io::Error::new(std::io::ErrorKind::Other, "mutex poisoned"))))?;
        let out = f(&mut guard)?;
        save_db(&self.path, &guard)?;
        Ok(out)
    }
}

impl CredentialApi for FileCredential {
    fn set_secret(&self, secret: &[u8]) -> Result<()> {
        self.with_db_mut(|db| {
            db.entries.insert(self.key.clone(), secret.to_vec());
            Ok(())
        })
    }

    fn get_secret(&self) -> Result<Vec<u8>> {
        let guard = self
            .db
            .lock()
            .map_err(|_| Error::PlatformFailure(Box::new(std::io::Error::new(std::io::ErrorKind::Other, "mutex poisoned"))))?;
        match guard.entries.get(&self.key) {
            Some(v) => Ok(v.clone()),
            None => Err(Error::NoEntry),
        }
    }

    fn delete_credential(&self) -> Result<()> {
        self.with_db_mut(|db| match db.entries.remove(&self.key) {
            Some(_) => Ok(()),
            None => Err(Error::NoEntry),
        })
    }

    fn as_any(&self) -> &dyn std::any::Any {
        self
    }

    fn debug_fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        std::fmt::Debug::fmt(self, f)
    }
}

#[derive(Debug)]
struct FileCredentialBuilder {
    path: PathBuf,
}

impl CredentialBuilderApi for FileCredentialBuilder {
    fn build(&self, target: Option<&str>, service: &str, user: &str) -> Result<Box<Credential>> {
        let cred = FileCredential::new(self.path.clone(), target, service, user)?;
        Ok(Box::new(cred))
    }

    fn as_any(&self) -> &dyn std::any::Any {
        self
    }

    fn persistence(&self) -> CredentialPersistence {
        CredentialPersistence::UntilDelete
    }
}

fn main() -> Result<()> {
    let path = PathBuf::from("./keyring-v3-file-store.json");

    // Entry を作る前にデフォルト builder を差し替える
    let builder: Box<CredentialBuilder> = Box::new(FileCredentialBuilder { path });
    set_default_credential_builder(builder);

    // 以降は v3 の Entry API をそのまま使える
    let entry = keyring::Entry::new("my-service", "my-user")?;
    entry.set_password("topS3cr3tP4$$w0rd")?;

    let password = entry.get_password()?;
    println!("password = {password}");

    entry.delete_credential()?;
    Ok(())
}

このように v3 でも「ストア」を自作すれば開発用途の永続化は可能ですが、ひと手間必要となります。そのうえ、

  • 競合(同時更新)
  • フォーマット破損
  • 暗号化
  • アトミックな書き込み

などを考え始めると、あっという間に「本格的なストア実装」になってしまいます。


v4の紹介:core とストア実装が分割され、default storeを起動時に選べる

v4ではissue #259 にある通り、コアAPI(cross platform library)と、各OS/方式の credential store実装が別crateとして分離されています。

  • keyring-core:cross platform library(API本体)
  • apple-native-keyring-store / windows-native-keyring-store / linux-keyutils-keyring-store:OSネイティブ系のストア実装
  • dbus-secret-service-keyring-store / zbus-secret-service-keyring-store:Secret Service のストア実装(同期/非同期)
  • keyring:CLI/サンプルアプリ + (利便性として)default store(デフォルトストア)接続のヘルパ(※v4系では Entry 本体は keyring-core 側)

この分割により、アプリ側は起動時に

  • 「どのストアを default store(デフォルトストア)にするか」
  • 「そのストアに渡す設定(modifiers)を何にするか」

を明示的に決められるようになっています。


v4の最小サンプル(macOS Keychain)

この例では、macOS の Keychain を default store(デフォルトストア)として利用します。

v4 では Entry などの API 本体は keyring-core 側にあります。そのため、アプリのコードでは keyring_core::Entry を直接使い、keyring crate は「どのストアを default store(デフォルトストア)にするか」を選ぶためのヘルパとして使う、という役割分担になります。

Cargo.toml

[dependencies]
# v4(pre-release / 記事執筆時点)
keyring = "4.0.0-alpha.5"

# Entry API 本体
keyring-core = "0.7"

main.rs(macOS Keychain)

fn main() -> keyring_core::Result<()> {
    // 1) ストアを選ぶ(ここが v4 のポイント)
    // macOS のネイティブ Keychain に接続する
    keyring::use_apple_keychain_store(&std::collections::HashMap::new())?;

    // 2) あとは Entry API で set/get/delete
    let entry = keyring_core::Entry::new("my-service", "my-user")?;
    entry.set_password("topS3cr3tP4$$w0rd")?;

    let password = entry.get_password()?;
    println!("password = {password}");

    entry.delete_credential()?;

    // 3) 明示的にストアを release する
    keyring::release_store();

    Ok(())
}

v4 の use_sample_store:開発・テスト時だけストアを差し替える

v4では、ストア差し替えの選択肢が増え、カスタマイズも容易になっています。

  • v3: mock ストアはあるが、ファイル永続化などは自作が必要
  • v4: 開発/テスト用途の sample store が公式に用意され、必要なら backing-file で永続化もできる

v4 の keyring crate では、ストアを「名前」で切り替えられるようになっています。

keyring crate が提供する接続ヘルパ(例):

  • use_named_store("sample")use_sample_store(...)
  • macOS では use_named_store("keychain")use_apple_keychain_store(...)
  • use_native_store(...) は OS ごとにネイティブストアを選択(macOS: Keychain)

また、use_sample_store(config)keyring_core::sample::Store を default store(デフォルトストア)として登録します。

sample store の性質

keyring-core の sample store は、開発・テスト向けの(オプションでファイル永続化できる)クロスプラットフォームストアです。公式ドキュメントでも secure/robust ではないので production で使わない と明言されています。

sample store は次の性質を持ちます。

  • 開発・テスト用途を強く意識したストア(OS のセキュアストレージ連携ではない)
  • 設定で backing-file を指定できる
    • 指定なし: ProcessOnly(プロセス内だけ。終了で消える)
    • 指定あり: UntilDelete(ファイルに永続化)
  • macOS/CI 観点での注意
    • ネイティブストア(Keychain 等)は環境によってダイアログ(許可/ロック解除/生体認証)が絡みやすく、非対話のCIやユニットテストとは相性が悪い
    • sample store はその問題を避けるための選択肢。ただし永続化は Drop 時に行われる設計で、default store(デフォルトストア)をプロセス全体で保持していると drop されず、ファイルに保存されない可能性がある
      • そのため keyring_core::sample::Store::save() が用意されている(必要なときに明示的に保存できる)

このように、開発/テスト用途のストアとして sample store が用意されています。

ストア選択(keyring crate / v4)

上の「最小サンプル」では use_apple_keychain_store(...) を呼びました。
実際のアプリでは、環境変数などで本番/開発を切り替えることになります。

keyring crate(v4)の use_named_store / use_sample_store は、内部で keyring_core::set_default_store(...) を呼び、以降の keyring_core::Entry が使う default store(デフォルトストア) を切り替えます。

加えて v4 には、ストア名だけでなく modifiers(key-value の追加指定) を渡せる API もあります。

  • keyring::use_named_store_with_modifiers(name, modifiers)

modifiers の意味(どのキーが使えるか、どう解釈されるか)は ストア実装ごとに決まります。
たとえば sample store は backing-file(永続化)や force-create(曖昧性を作るテスト用途)を解釈します。

fn configure_store() -> keyring_core::Result<()> {
    let env = std::env::var("APP_ENV").unwrap_or_else(|_| "dev".to_string());

    if env == "prod" {
        // macOS の CLI/非サンドボックス用途なら、login keychain(= Keychain ストア)
        keyring::use_named_store("keychain")
    } else {
        // dev/test: sample store を使う(必要なら永続化)
        let config = std::collections::HashMap::from([("backing-file", "keyring-sample-data.ron")]);
        keyring::use_named_store_with_modifiers("sample", &config)
    }
}

保存/取得/削除(keyring-core::Entry)

ストアを設定した後は、keyring_core::Entry を v3 とほぼ同じ感覚で使えます。

use keyring_core::{Entry, Result};

fn main() -> Result<()> {
    // 事前に keyring::use_named_store(...) / use_sample_store(...) 等で
    // default store(デフォルトストア)がセットされている前提

    let entry = Entry::new("my-service", "my-name")?;
    entry.set_password("topS3cr3tP4$$w0rd")?;

    let password = entry.get_password()?;
    println!("password = {password}");

    entry.delete_credential()?;
    Ok(())
}

backing file を使って永続化

v3 で「OS の Keychain に触れず、プロセスをまたいで値は残したい」という要件を満たすには、credential store を自作する必要がありました。

v4 ではこの要件に対して、開発・テスト向けの sample storebacking-file を渡すだけで「簡易な永続化」をできます。

  • v3: 自作ストア(JSON/暗号化/排他/破損耐性…を自前で考える必要)
  • v4: sample store + backing-file(※あくまで開発・テスト向け。本番利用は非推奨)

1) backing-file を指定して起動する

use_named_store_with_modifiers("sample", ...)backing-file を渡すと、sample store は起動時にそのファイルを読み込み、メモリ上のストアに復元します。

use std::collections::HashMap;

fn configure_dev_store() -> keyring_core::Result<()> {
    // ※キーは "backing-file"(ハイフン)
    let mods = HashMap::from([("backing-file", "./keyring-sample-data.ron")]);
    keyring::use_named_store_with_modifiers("sample", &mods)
}

2) 保存タイミングに注意:自動では「常に」永続化されない

sample store の backing file は、set_password のたびに自動で更新されません。

  • 明示的に Store::save() を呼んだとき
  • もしくは store が drop されたとき(最後の参照が解放されたとき)

にだけ保存されます。

特に v4 の default store は内部的に static に保持されるため、プロセス終了まで drop されず 「終了時に自動保存されない」ように見えるケースがあります。

そのため、永続化したい場合は以下のどちらか(または両方)を意識すると安全です。

  • keyring::release_store() で default store を明示的に解放する
  • sample store を使っていると分かっている場面では、downcast して Store::save() を呼ぶ

3) (例)終了前に release して保存させる

fn main() -> keyring_core::Result<()> {
    configure_dev_store()?;

    let e = keyring_core::Entry::new("svc", "user")?;
    e.set_password("secret")?;

    // default store を明示的に解放(= drop を促す)
    // backing-file 付き sample store なら、このタイミングで保存が走る
    keyring::release_store();

    Ok(())
}

まとめ

  • Keyring は OS のセキュアストレージを横断して機密情報を扱える Rust 製エコシステム(入口としては keyring crate が分かりやすく、v4 系では API 本体は keyring-core に分離されている)
  • v3 では Entry による素直な API で保存/取得/削除ができる一方、開発・テストでは OS 依存の煩雑さが顕在化しがち
  • v4 ではストアを差し替える設計が進み、use_sample_store により「開発/テスト時だけ OS ストアに触らない」運用が取りやすくなる(必要なら backing-file で簡易永続化も可能)
  • API 自体はクロスプラットフォームだが、実際に使うストア実装は OS に依存する
  • sample store は backing-file を指定してファイル永続化もできる(ただし secure/robust ではないので production では使わない)
1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?