この記事で扱うこと
※以降のサンプルコードは macOS を前提にしています(他OSでも基本APIは同じですが、ストア実装は変わります)。
- Keyring(Rust)の概要:OS のセキュアストレージを横断して機密情報(secret)を保存・取得できる
- v3 の最小サンプル(
Entry::new/set_password/get_password/delete_credential) - 開発時のペインポイント:OS の Keychain に依存するとテストが面倒になりがち
- v3 の回避策
- 回避策(1):
mockストアに差し替えて、ネイティブストアに触らずテストする - 回避策(2): (応用)クライアント提供ストアを自作して“簡易なファイル永続化”をする
- 回避策(1):
- 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/サンプルを担う構成になっています。
参考:
- keyring リポジトリ: https://github.com/open-source-cooperative/keyring-rs
- keyring の新設計(v4 の設計背景): https://github.com/open-source-cooperative/keyring-rs/wiki/Keyring
- v4 に向けたリポジトリ再編提案: https://github.com/open-source-cooperative/keyring-rs/issues/259
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 の読み書きに serde と serde_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 store に backing-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 製エコシステム(入口としては
keyringcrate が分かりやすく、v4 系では API 本体はkeyring-coreに分離されている) - v3 では
Entryによる素直な API で保存/取得/削除ができる一方、開発・テストでは OS 依存の煩雑さが顕在化しがち - v4 ではストアを差し替える設計が進み、
use_sample_storeにより「開発/テスト時だけ OS ストアに触らない」運用が取りやすくなる(必要ならbacking-fileで簡易永続化も可能) - API 自体はクロスプラットフォームだが、実際に使うストア実装は OS に依存する
- sample store は
backing-fileを指定してファイル永続化もできる(ただし secure/robust ではないので production では使わない)