Rust でローカルにできる限りにセキュアに保存

に公開

Rust で他人に抜かれたらまずいデータをローカルに保存したいときにどうするか、ちょっと調べたり考えたりしたまとめ。

主にこの記事では暗号化する鍵(OsRnd とか使った 自動生成の鍵 を想定)の扱い方をローカルでどうするかです。暗号化方法については適宜選べばよいでしょう。ChaCha20Poly1305 あたりで十分かと個人的には思います。

クラウドを使えやクソデブって?うん、それでいいんじゃない?

keyring

keyring クレートを使うと、 OS 固有の安全なストアに統一的にアクセスする方法を提供してくれます。

[workspace.dependencies]
keyring = "3.6.3"

[target.'cfg(target_os = "windows")'.dependencies]
keyring = { workspace = true, features = ["windows-native"]}

[target.'cfg(target_os = "linux")'.dependencies]
keyring = { workspace = true, features = ["sync-persistent", "crypto-rust"]}

[target.'cfg(target_os = "apple")'.dependencies]
keyring = { workspace = true, features = ["apple-native"]}

Windows なら資格情報マネージャとかいうやつで、 2500文字 ぐらいまで保存できます。
Mac は知らないし微塵も興味ないですが、多分上記の設定でいけるんじゃないの。

で、厄介なのが Linux で、 カーネルが提供する永続的な保存領域というのがないのです。

本家では Cargo.toml に keyring = { version = "3", features = ["apple-native", "windows-native", "sync-secret-service"] } と書けと書かれてるのを、私がわざわざ上記のように分解したのは、 Linux でどうするかちょっと考えないとだめだからですね。

使い方はかなり単純で、以下のように使います。このサンプルだけで使い方を理解するには十分でしょう。

#[derive(Debug, clap::Parser)] struct Arg {
    #[arg(short)] write: bool, // cargo run -- -w
    #[arg(short)] delete: bool,// cargo run -- -d
}

fn main() -> anyhow::Result<()> {
    println!("KOKO");
    use clap::Parser;
    let arg = Arg::parse();
    let entry = keyring::Entry::new("Nodamushi", "")?;

    if arg.write {// 書き込み
        println!("Write Entry");

        match entry.set_password("FooBar!") {
            Ok(_) => {}
            Err(e @ keyring::Error::PlatformFailure(_)) => {
                println!("DBus not supported ?");
                return Err(e.into());
            }
            Err(e) => return Err(e.into()),
        }

        // 実質的にこれと同じ(エラー処理は省略)
        // let write_data = "FooBar!".as_bytes();
        // entry.set_secret(write_data)?;

    } else { // 読み込み
        println!("Read Entry");

        match entry.get_password() {
            Ok(password) => {
                println!("Passowrd: {}", password);
            }
            Err(keyring::Error::NoEntry) => println!("--Entry Not found.--"),

            Err(e @ keyring::Error::PlatformFailure(_)) => {
                println!("DBus not supported ?");
                return Err(e.into());
            }
            Err(e) => return Err(e.into()),
        };
    }
    if arg.delete {// 削除
        println!("Delete Entry");

        match entry.delete_credential() {
            Ok(_) => {}
            Err(keyring::Error::NoEntry) => println!("--Entry Not found.--"),

            Err(e @ keyring::Error::PlatformFailure(_)) => {
                println!("DBus not supported ?");
                return Err(e.into());
            }
            Err(e) => return Err(e.into()),
        }
    }
    Ok(())
}

Linux でどーすんの

keyring と違い開発は止まっているクレートですが cryptex というものもあります。こちらも同様に DBus を使うのですが、feature で file を指定すると、SQL Cipher を使って保存してくれます。これを使ってみることも考慮しても良いかもしれません。

で、環境変数だの何だのあれこれ考えていたんですが、 結局 パーミッション 600 の平文でパスワード保存でよくね?ssh だってそうじゃん。 という結論に至りました。

環境依存になってしまいますが systemd-homed で暗号化しておけばいいんですよ。Windows にしても結局悪意あるログインされてしまったら普通にパスワード抜けますからね。

プロセス側であれこれ考えたって、結局ローカルにあるという時点で限界はあります。

なら、プロセス側は極力単純に最低限の防御(暗号化)はしつつ、あとは環境にお任せでいいのではと。 DBus が入ってるかとか、 SQL Cipher が入ってるかとか、 OpenSSL とか環境変数とか、環境依存をプロセス側が考えるのが面倒クセェ。

鍵の消去

鍵がメモリに残るのはよろしくない。

調べてみたら zeroize クレートを使うと良いらしい。

cargo add zeroize -F derive
#[derive(zeroize::Zeroize, zeroize::ZeroizeOnDrop, Debug)]
struct MyPassword(pub String);

fn main() {
    use zeroize::Zeroize;
    let mut password = MyPassword("FooBar!".into());
    println!("password: {:?}", password);
    password.zeroize(); // ZeroizeOnDrop を実装してるので明示的に呼ぶ必要はない
    println!("password: {:?}", password);
}

結果はこんな感じ。

password: MyPassword("FooBar!")
password: MyPassword("")

ただ、 zeroize を呼び出していない、ないし、 Drop で zeroize を呼ばないものに関してはメモリに残ってしまいます。実装の際は move や copy、Vec, String の扱いは慎重にしなければならないでしょう。

ファイルから読み込むなりすると、どうしてもなにかのメモリを介するから、どこかのバッファに残ってしまいそう。乱数でプロセス上で直接生成して使うとかなら完全にメモリを管理できそうですが……

完全にこれを使いこなすのはかなり難しそうだなという感想です。

マスターキーを使うことを避ける

OSに保存したパスワードが見えてしまえば、一発でデータが抜かれてしまいます。それを回避するには、マスターパスワードから作業パスワードを生成するようにすれば、攻撃者はプログラム解析の手間も増えるのでいいでしょう。

といっても、所詮はローカル環境。Argon2 だの何だのまで使う必要はなくて、固定値ないし鍵(自動生成)を2個作って xor するとかで十分なんじゃないかなーと個人的には思います。

まぁ、xor があれなら BLAKE3 とかが速いとのことでいいのでは。

cargo add secrecy # zeroize のラッパー
cargo add blake3 -F zeroize # zeroize 有効化した BLAKE3
fn create_key_256bit(master_password: &secrecy::SecretSlice<u8>) -> secrecy::SecretSlice<u8> {
    use secrecy::ExposeSecret;
    use zeroize::Zeroize;
    let mut hasher = blake3::Hasher::new();

    hasher.update(master_password.expose_secret());
    hasher.update("saltは埋め込みでもいいんじゃね".as_bytes());
    let mut hash = hasher.finalize();
    hasher.zeroize();

    let vec = hash.as_bytes().to_vec();
    hash.zeroize();

    vec.into()
}

私のまとめ

  • Windows, Apple:
    • keyring で OS にパスワードを保存、管理してもらう
    • そのパスワードでデータを暗号化・復号化
      • 必要ならマスターキーからパスワードを生成
  • Linux:
    • とりあえず keyring で試すだけは試す?
    • keyring が駄目なら or 最初から、パーミッション 600 のディレクトリをユーザーホームの下に掘って、そこにパスワードを平文で保存
      • ユーザーには systemd-homed で暗号化することをお願いする
    • そのパスワードでデータを暗号化・復号化
      • 必要ならマスターキーからパスワードを生成
  • メモリから消去したいなら zeroize クレートを使う
    • でも完璧を目指すのは難しそうね

なんにせよ、ローカルのデータが抜かれてるって時点で完璧は不可能でしょう。TPM とか外部の認証をさらに設けるとかどこまでやるかは、どこまでセキュリティを担保しないといけないかによるでしょう。

セキュリティに強い専門家からの人格罵倒をお待ちしてます。(セキュリティ関連の人は相手を人格非難しても何故か許されるよね)

Discussion