🦀

Rust の型システムで暗号処理の誤用を防ぐ

に公開

本記事は、.env を暗号で守るための Rust 製 CLI、SecretEnv の内部で動いている暗号処理を解説します。SecretEnv は、.env の各値をメンバーごとの公開鍵で暗号化することで、対応する秘密鍵を持つ本人以外には読めない機密性を実現するツールです。

「署名未検証の暗号ファイルを復号に渡せない」「秘密情報を Debug でログに出せない」「読み込んだ既存の nonce を再使用できない」— SecretEnv の Rust 実装ではこれらがすべてコンパイル時に弾かれます。暗号処理の誤りを型設計によってコンパイル時に防ぐ仕組みが、実装の中でどのように組み込まれているかを、クレート構成と内部モジュール階層から順に見ていきます。

想定する場面

次のコマンドの裏側を題材にします。

secretenv get DATABASE_URL

get は暗号化ファイルを読み込み、署名を検証し、信頼判断を経て、自分宛ての包みを復号し、指定した変数の値を返します。この一連の処理は、Rust の型システムが「検証前のデータを復号に使えない」という制約をコンパイル時に強制することで、実装上の誤りを未然に防いでいます。

型システムがどのような設計でこれを実現しているかを、クレート構造から順に見ていきます。

クレートの分割と責務

SecretEnv は 2 つのクレートに分かれています。

  • secretenv: CLI root crate。引数解釈、対話プロンプト、stdout / stderr 表示、終了コードを扱います
  • secretenv-core: library crate。暗号処理、保存形式の読み書き、ローカルキーストア、信頼ストアを扱います

この分割の目的は、CLI 固有の関心事を library に持ち込まないことです。secretenv-core は対話プロンプトを実行しません。SSH 鍵の選択プロンプトも、trust review の表示も、すべて CLI 側が担当します。secretenv-core は処理の結果として「利用者の確認が必要」という判定を返し、それをどう見せるかは呼び出し元が決めます。

外部アプリケーションが secretenv-core を組み込む場合、触れてよい入口は api モジュール配下だけです。

// 外部アプリから利用する場合の import 例
use secretenv_core::api::kv::KvEncArtifact;
use secretenv_core::api::key::LocalKeyStore;
use secretenv_core::api::trust::TrustPolicyEvaluator;
use secretenv_core::{Error, ErrorKind};

featureioformatmodelcryptoconfigsupport といった下位実装モジュールは外部公開されていません。公開 surface が api に限定されることで、内部の実装変更が外部アプリに影響しない境界を保っています。

secretenv-core の内部モジュール階層

api の下には複数の実装モジュールが積み重なっています。外部から見えないこの階層が、api の各 facade メソッドの実際の処理を担います。全体の構成は次の図のようになっています。

外部アプリケーションが触れる入口は apipreludeError/ErrorKind/Result の 3 種だけです。first-party CLI (secretenv) だけが cli-internal feature を通じて cli_api にアクセスできます。それより下の実装モジュールは crate-private であり、外部から到達する方法はありません。

各層の役割をまとめると次のとおりです。

モジュール 役割
feature ビジネスロジック層。kv-enc 操作・暗号化・復号・rewrap・署名検証・信頼判断などのユースケースを実装する
model ドメインデータ型。PublicKey・PrivateKey・kv-enc エントリ・trust store などのデータ構造を定義する
format wire format との相互変換。JSON・kv-enc テキストとドメイン型を変換し、JSON 正規化 (JCS) を担う
crypto 暗号アルゴリズムの実装。HPKE・XChaCha20-Poly1305・Ed25519・HKDF-SHA256
io 外部 I/O アダプター。ファイルシステム・SSH エージェント・GitHub API・子プロセス実行を担う
config / support 設定解決と汎用ユーティリティ(base64 codec・atomic ファイル操作・zeroize 型など)

featureio の分担が設計の要点の 1 つです。feature 層がどのメンバーに wrap を作るかを決め、実際のファイルへの書き出しは io に委譲します。判断ロジックと外部アクセスを分離することで、feature はファイルシステムの詳細を知らずにビジネスロジックを記述できます。

層を横断する処理の流れ

KvEncArtifact::verify() が呼ばれたときに各層がどう連携するかを追ってみます。

呼び出し元には VerifiedKvEncArtifact だけが見えます。feature のどのサブモジュールが呼ばれ、crypto でどのアルゴリズムが走ったかは隠れています。この「処理の詳細を隠す」という性質が、api という安定した外部 surface を保てる理由です。こうした surface の制約は、Rust のモジュール可視性と feature flag によってコンパイル時に維持されています。

型状態遷移で検証を強制する

get の read-path では、暗号化ファイルを読み込んですぐに復号へ進むことはできません。型設計がその順序を強制しています。

処理の流れを型の変化として書くと次のようになります。

// 1. 未検証 artifact — 復号 API は存在しない
let artifact: KvEncArtifact = KvEncArtifact::load(path)?;

// 2. 署名検証を通過して初めて検証済み型に変わる
let verified: VerifiedKvEncArtifact = artifact.verify(&options)?;

// 3. 検証済み型だけが復号 API を持つ
let value: SecretString = verified.decrypt_entry(&key_ctx, "DATABASE_URL", &opts)?;

KvEncArtifact には decrypt_entry() メソッドが存在しません。artifact.decrypt_entry(...) と書いた時点でコンパイルエラーになります。検証を飛ばして復号へ進む経路が、型レベルで封じられています。

これは type-state pattern と呼ばれる設計です。「まだ検証していない状態」と「検証済みの状態」を別の型として表すことで、状態遷移の順序をコンパイラが保証します。実行時のチェックではなく、型チェックとして機能します。

同じパターンは file-enc 形式のファイルにも適用されています。

// file-enc も同じ型状態遷移を持つ
let artifact: FileEncArtifact = FileEncArtifact::load(path)?;
let verified: VerifiedFileEncArtifact = artifact.verify(&options)?;
let bytes: SecretBytes = verified.decrypt_bytes(&key_ctx, &opts)?;

VerifiedKvEncArtifactrecipient_set_subject() も公開しており、trust policy の評価もこの検証済み型を経由します。署名検証を通過したデータだけが信頼判断の材料として使える設計です。

write-path でも同じ型設計が使われる

secretenv get DATABASE_URL の read-path は型状態遷移で検証を強制していました。同じ型設計は write-path にも適用されています。

secretenv set DATABASE_URL "postgres://..." の裏側を見てみます。

// write-path: 既存 artifact に新しい値を追加する
let artifact: KvEncArtifact = KvEncArtifact::load(path)?;

// write-path でも verify が必要
// 検証を通過しないと既存エントリに書き込めない
let verified: VerifiedKvEncArtifact = artifact.verify(&options)?;

// trust policy の評価 (read-path と同じフロー)
let subject = verified.recipient_set_subject();
// ... TrustPolicyEvaluator で評価 ...

// 新しいエントリを追加して再署名した artifact を受け取る
let new_entry = KvInputEntry::new("DATABASE_URL", &new_value);
let new_artifact: KvEncArtifact =
    verified.set_entries(&[new_entry], &recipients, &key_ctx, &opts)?;
new_artifact.save(path)?;

注目してほしいのは、set_entries()VerifiedKvEncArtifact に対して呼ぶ操作であるという点です。KvEncArtifact(未検証)に対して set_entries() を呼ぶ方法はありません。

これには理由があります。set_entries() は既存エントリを保持したまま新しいエントリを追加・置換します。既存エントリが改ざんされたファイルから来ていた場合、その改ざんが検証なしに新しいファイルに引き継がれてしまいます。verify() を経ることで、既存エントリは「署名が正しいファイル由来のもの」として扱えます。

rewrap も同じフローです。受信者集合を更新するためには、現在のファイルを verify() してから操作します。これにより、改ざんされたファイルの wrap から受信者情報を読み取る誤りを防いでいます。

read-path と write-path で同じ型状態遷移を経ることで、「未検証の状態から値を読む」と「未検証の状態に値を書く」という 2 種類の誤りが両方コンパイルエラーになります。型設計が read と write の対称性を保証しています。

salt と nonce の再利用を型で禁止する

暗号処理では、値を「正しい型」に入れるだけでは足りない場面があります。たとえば kv-enc のエントリ salt や XChaCha20-Poly1305 の nonce は、形式としては 32 バイトや 24 バイトの固定長バイト列です。しかし、重要なのは長さだけではありません。同じ値の再利用が致命的な問題となるため、暗号化のたびに新しく生成された値を使うことを徹底することが大切です。

SecretEnv では、この性質も型を使って表すようにしています。

// 保存済み artifact から decode した nonce
let nonce: XChaChaNonce = decode_nonce(entry.nonce)?;

// 暗号化直前に生成した fresh nonce
let fresh_nonce: FreshXChaChaNonce = FreshXChaChaNonce::generate()?;

XChaChaNonce は、保存済み artifact から読み出した nonce を表します。復号ではこの値が必要です。一方、暗号化で使う nonce は FreshXChaChaNonce として扱います。この型は任意の [u8; 24] から直接構築できず、乱数生成 API を通じてのみ作成できます。

kv-enc のエントリ salt も同じです。

let fresh_salt: FreshKvSalt = FreshKvSalt::generate()?;
let cek = derive_cek_from_fresh_salt(master_key, &fresh_salt, sid, key)?;

ここで derive_cek_from_fresh_salt()FreshKvSalt を受け取ります。暗号化パスでは、保存済みファイルからデコードした salt をそのまま渡せません。新しいエントリを作る経路では、fresh salt を生成してから CEK を派生する、という順序が型で強制されます。

PrivateKey の保護でも同じ考え方を使っています。秘密鍵を保存用に暗号化するときは、ikm_salthkdf_salt をまとめた FreshPrivateKeyProtectionMaterial を生成します。復号時に JSON からデコードした salt と、暗号化時に生成した salt 値は別の役割として扱います。

let material = FreshPrivateKeyProtectionMaterial::generate()?;
let alg = build_argon2id_algorithm(&material);

この設計で型が保証するのは、「この値は SecretEnv の fresh 生成 API を通って作られた」という事実です。乱数源の品質や生成値の一意性は、OS の CSPRNG と十分なビット長に依存します。型は暗号を置き換えるものではなく、暗号処理に渡す値の作り方を間違えにくくするためのツールです。

Opaque facade が内部を封じる

get の read-path には、KeyContext という型が登場します。復号に使う鍵のコンテキストを持つ型ですが、外部から直接構築することはできません。

// KeyContext はローカルキーストアからロードする
let key_ctx: KeyContext = key_store.load_key_context(&options)?;

// 内部の秘密鍵マテリアルや Ed25519 署名鍵には触れない
// key_ctx.signing_key  — このようなアクセサは存在しない
// key_ctx.private_key  — これも存在しない

// 外部から使えるのは accessor だけ
let handle = key_ctx.member_handle();
let kid = key_ctx.kid();

KeyContext は opaque facade 型です。内部に秘密鍵マテリアルや Ed25519 署名鍵を持っていますが、それらへの直接アクセスは外部 API に含まれません。呼び出し元がアクセスできるのは、member handle や kid のような識別情報だけです。

同じ設計は RecipientKeysTrustApproval にも適用されています。

// RecipientKeys は load_recipient_keys() で作る
let recipients: RecipientKeys = key_store.load_recipient_keys(&handles, &options)?;

// TrustApproval は from_request() で作る
let approval: TrustApproval = TrustApproval::from_request(&review_request);

// いずれも内部フィールドへの直接アクセスはできない

なぜ opaque にするかというと、型が持つ不変条件を維持するためです。KeyContext は「ロードと検証を経た鍵コンテキスト」である必要があります。直接構築できてしまうと、検証を省いたコンテキストを渡せてしまいます。TrustApproval は「利用者が明示的に承認した内容だけを表す」必要があります。直接組み立てられてしまうと、承認なしに approval オブジェクトを作れてしまいます。

opaque 型が提供する API の範囲が、「何ができて何ができないか」の境界そのものです。

秘密情報を型で閉じ込める

decrypt_entry()SecretString を返します。この型は、中身をうっかりログに出さないための設計が施されています。

let value: SecretString = verified.decrypt_entry(&key_ctx, "DATABASE_URL", &opts)?;

// Debug 表示は値を隠す
println!("{:?}", value);
// 出力: SecretString([REDACTED])

// 値を使うには明示的な boundary API を呼ぶ
let plain: &str = value.expose_secret();

// 出力目的で所有権ごと渡す場合
let output: String = value.into_plain_string_for_output();

SecretStringDebug 実装は値を表示しません。format!{:?} でログに出しても、実際の値は現れません。値を使いたい場合は expose_secret() を明示的に呼ぶ必要があります。API の名前自体が「秘密情報を公開する操作」という意図を表しています。

into_plain_string_for_output() は所有権を消費します。呼んだ後は value を使えません。これにより、「出力境界を通過した後も SecretString が手元に残る」という状況を防いでいます。

バイト列を返す SecretBytes も同じ設計です。expose_secret() で明示的に borrow し、into_zeroizing_vec() でゼロ化バッファに変換します。

どちらの型も、ドロップ時にメモリをゼロ化します。zeroize クレートを使ったゼロ化により、秘密情報がメモリに残り続け、swap ファイル等に書き出されるリスクを下げます。ガベージコレクタのない Rust の世界では、この種のゼロ化が実装可能であり、SecretEnv はそれを型の設計として組み込んでいます。

この設計で守れること

  • 未検証ファイルを復号に使う経路がコンパイルエラーになる
  • 暗号化パスで fresh に生成された salt / nonce と、保存済みファイルからデコードした salt / nonce を型で区別できる
  • SecretStringDebug 表示が値を表示しないため、うっかりログに値が出る事故を防げる
  • KeyContextRecipientKeysTrustApproval の内部を caller が直接組み立てられないため、不変条件を維持した状態での利用だけが可能になる

残る制約

  • 型は「呼び出し順序の強制」と「surface の制限」を担当する。実行時の論理ミス(たとえば expose_secret() した後の値を正しく扱わないこと)は型では防げない
  • into_plain_string_for_output() を呼んだ後、返った String の扱いは caller に委ねられる
  • fresh 型は「生成 API を通った」という来歴を表す。乱数源の品質や全期間の一意性は、CSPRNG と十分なビット長に依存する
  • feature flag の組み合わせが正しく設定されているかは、コンパイルが成功することとは別の問題である
  • エラー本文に秘密情報が混入する可能性がある。Error を受け取った caller は、その内容をそのままログに流してはいけない

ここで見えてくることは、型による安全設計の「できること・できないこと」が、暗号の「できること・できないこと」と同じ構図を持っているということです。型は「未検証データの誤用」や「秘密情報の型外漏洩」を防ぎますが、「実行時の判断ミス」や「credential 管理の運用上の誤り」は防ぎません。暗号が「過去に開示された値の回収」を保証しないのと同じように、型も「実行後の値の扱い」を保証しません。型は実装の補助線であり、運用上の判断と組み合わせて初めて意味を持ちます。

関連リンク

https://github.com/ebisawa/kapsaro

Discussion