テックブログ

Rust×mTLSで金融API保護

Rust×mTLSで金融API保護

1. 目的と前提:Rustで“壊れにくい金融API”を作る

この記事では、Rust(Axum/Actix)で小さめの金融系APIを作るときに、

  • mTLS(相互TLS)で入口をしっかり認証する
  • レート制限で誤発火や濫用から守る
  • 構造化ログ&分散トレースで監査と運用を両立する

という「最小限だけど本番に耐えやすいセット」を Rust でどう実現するかを説明します。

想定読者は「エンジニア1年目〜2年目で、RustやWeb APIを触り始めた人」。
細かいTLSの理論や暗号アルゴリズムの話は深入りせず、「実務でとりあえずこれを入れておくとだいぶマシになる」という視点で書いていく。

技術スタックの前提はだいたい次のようなイメージ。

  • 非同期ランタイム:Tokio
  • Webフレームワーク:Axum または Actix Web
  • TLS実装:Rustls / tokio-rustls(TLS 1.3)
  • ミドルウェア:Tower / tower-http を利用
  • ログ:tracing 系でJSONログ

2. mTLS(相互TLS):クライアント証明書で入口を堅くする

2-1. 何をやるかの全体像

金融APIでは、「相手が本当に想定しているクライアントか」を強く保証したい場面が多い。
そこで使うのがmTLS(相互TLS)

ざっくり流れはこう。

  1. サーバは通常どおりサーバ証明書を提示
  2. クライアントも自分のクライアント証明書を提示
  3. サーバ側で「このクライアント証明書は信頼できるCAで発行されているか」を検証
  4. 証明書のDNなどからクライアントIDを取り出して、リクエストの属性として持たせる

証明書は事前配布しておき、有効期限の管理と、誤発行時に失効(Revoke)できる仕組み(CRL/OCSP)を用意しておく。

2-2. RustlsでmTLSサーバを立てる(ざっくり例)

ここでは Axum ベースで、tokio-rustls を使って mTLS を有効にしたサーバの簡略版。

use std::{fs, sync::Arc};
use axum::{routing::get, Router};
use tokio_rustls::rustls::{
    self, Certificate, PrivateKey, ServerConfig, RootCertStore, AllowAnyAuthenticatedClient,
};
use tokio_rustls::TlsAcceptor;
use tokio::net::TcpListener;
use hyper::server::conn::Http;

fn load_certs(path: &str) -> Vec<Certificate> {
    let cert = fs::read(path).expect("cert file");
    let mut reader = std::io::Cursor::new(cert);
    rustls_pemfile::certs(&mut reader)
        .unwrap()
        .into_iter()
        .map(Certificate)
        .collect()
}

fn load_key(path: &str) -> PrivateKey {
    let key = fs::read(path).expect("key file");
    let mut reader = std::io::Cursor::new(key);
    let keys = rustls_pemfile::pkcs8_private_keys(&mut reader).unwrap();
    PrivateKey(keys[0].clone())
}

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    // サーバ証明書と秘密鍵を読み込み
    let certs = load_certs("server.crt");
    let key = load_key("server.key");

    // クライアント証明書を検証するためのCA(ルート証明書)
    let client_ca_certs = load_certs("client_ca.crt");
    let mut client_root_store = RootCertStore::empty();
    for c in client_ca_certs {
        client_root_store.add(&c)?;
    }

    let client_auth = AllowAnyAuthenticatedClient::new(client_root_store);

    let mut tls_config = ServerConfig::builder()
        .with_safe_defaults()
        .with_client_cert_verifier(client_auth) // クライアント証明書必須
        .with_single_cert(certs, key)?;

    // 弱い暗号スイート無効化など、必要に応じて調整
    tls_config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()];

    let tls_config = TlsAcceptor::from(Arc::new(tls_config));

    // Axumのルータ
    let app = Router::new().route("/healthz", get(|| async { "ok" }));

    // TCPリスナーを作成
    let listener = TcpListener::bind("0.0.0.0:8443").await?;

    loop {
        let (stream, addr) = listener.accept().await?;
        let app = app.clone();
        let tls_config = tls_config.clone();

        tokio::spawn(async move {
            let tls_stream = match tls_config.accept(stream).await {
                Ok(s) => s,
                Err(e) => {
                    eprintln!("TLS handshake error from {}: {:?}", addr, e);
                    return;
                }
            };

            if let Err(err) = Http::new()
                .http2_only(false)
                .serve_connection(tls_stream, app)
                .await
            {
                eprintln!("server error: {:?}", err);
            }
        });
    }
}

このコードでは、Rustls の AllowAnyAuthenticatedClient を使って「指定したCAで発行されたクライアント証明書以外は入れない」サーバを立てている。TLSハンドシェイクの時点でクライアント証明書をチェックさせることで、アプリ側からのアクセス以外はそもそもAPIに到達しないようにする。alpn_protocolsh2 を含めて HTTP/2 を優先し、レイテンシの改善も狙う。

2-3. クライアント証明書からクライアントIDを取り出す

多くの場合、「この証明書はどのシステム(クライアント)用なのか」を証明書のDNやSANに埋め込む。
Rustlsでは、ハンドシェイク後にクライアント証明書チェーンを取り出して、その中からクライアントIDを抽出し、リクエストのコンテキストに埋め込む流れ。

Axumでは、Towerレイヤーで拡張情報に入れる形が典型(イメージ)。

// 疑似コードイメージ。
use axum::extract::Request;
use tower::{Layer, Service};

struct ClientIdLayer;

impl<S> Layer<S> for ClientIdLayer {
    type Service = ClientIdMiddleware<S>;
    fn layer(&self, inner: S) -> Self::Service {
        ClientIdMiddleware { inner }
    }
}

struct ClientIdMiddleware<S> {
    inner: S,
}

impl<S, B> Service<Request<B>> for ClientIdMiddleware<S>
where
    S: Service<Request<B>>,
{
    type Response = S::Response;
    type Error = S::Error;
    type Future = S::Future;

    fn poll_ready(
        &mut self,
        cx: &mut std::task::Context<'_>,
    ) -> std::task::Poll<Result<(), Self::Error>> {
        self.inner.poll_ready(cx)
    }

    fn call(&mut self, mut req: Request<B>) -> Self::Future {
        // TLS セッション情報からクライアント証明書を取り出す
        // client_id を抽出して extensions にセットするイメージ
        // req.extensions_mut().insert(ClientId(client_id_string));
        self.inner.call(req)
    }
}

このコードでは、ミドルウェアでリクエストを横取りし、クライアント証明書から取り出した client_idextensions に差し込むことで、ハンドラ側から Extension<ClientId> として利用できるようにする。すべての処理で「どのクライアントから来たか」を参照しやすくなる。

2-4. 運用チェックリスト

  • 証明書の有効期限が30日を切ったらアラート
  • 失効リスト(CRL)やOCSP staplingを設定しておき、誤発行時に止められるようにする
  • 新しい証明書に切り替えるときは、旧証明書と並行稼働期間を作る
  • 弱い暗号スイートを無効化し、TLS 1.2 は必要なければ OFF、基本は TLS 1.3

3. レート制限:誤発火・API濫用から守る

3-1. IP+クライアントIDの二段絞り

金融APIでは、「アプリのバグで大量リクエストが飛ぶ」「攻撃的に叩かれる」といった場面を想定。
次の二段構成がよく使われる。

  • IPアドレスベースの制限(ネットワーク的な異常をざっくり止める)
  • クライアントIDベースの制限(特定クライアントの暴走を抑える)

実装的には、IPはLBやAPI Gateway側、クライアントIDはアプリ側(Rust)でチェック、などの分担もよくある。

3-2. Rustで簡易レート制限(Governor+Tower)

シングルノード前提でGovernorを使った簡易レート制限の例。

use axum::{routing::get, Router};
use governor::{
    clock::DefaultClock,
    state::InMemoryState,
    Quota, RateLimiter,
};
use nonzero_ext::nonzero;
use std::{num::NonZeroU32, sync::Arc};
use tower::{Layer, Service};
use http::{Request, StatusCode};
use std::task::{Context, Poll};
use futures_util::future::BoxFuture;

#[derive(Clone)]
struct RateLimitLayer {
    limiter: Arc<RateLimiter<String, InMemoryState, DefaultClock>>,
}

impl RateLimitLayer {
    fn new() -> Self {
        let quota = Quota::per_second(nonzero!(10u32)); // 1秒あたり10リクエスト
        let limiter = RateLimiter::keyed(quota);
        Self { limiter: Arc::new(limiter) }
    }
}

impl<S> Layer<S> for RateLimitLayer {
    type Service = RateLimitMiddleware<S>;

    fn layer(&self, inner: S) -> Self::Service {
        RateLimitMiddleware {
            inner,
            limiter: self.limiter.clone(),
        }
    }
}

#[derive(Clone)]
struct RateLimitMiddleware<S> {
    inner: S,
    limiter: Arc<RateLimiter<String, InMemoryState, DefaultClock>>,
}

impl<S, B> Service<Request<B>> for RateLimitMiddleware<S>
where
    S: Service<Request<B>>, 
    S::Response: From<axum::response::Response>,
    S: Clone + Send + 'static,
    S::Future: Send + 'static,
{
    type Response = S::Response;
    type Error = S::Error;
    type Future = BoxFuture<'static, Result<Self::Response, Self::Error>>;

    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
        self.inner.poll_ready(cx)
    }

    fn call(&mut self, req: Request<B>) -> Self::Future {
        let mut inner = self.inner.clone();
        let limiter = self.limiter.clone();

        // 実際には client_id をキーにする
        let key = req
            .headers()
            .get("x-client-id")
            .and_then(|v| v.to_str().ok())
            .unwrap_or("unknown")
            .to_string();

        BoxFuture::pin(async move {
            if limiter.check_key(&key).is_err() {
                // 429を返す
                let mut res = axum::response::Response::new("Too Many Requests".into());
                *res.status_mut() = StatusCode::TOO_MANY_REQUESTS;
                return Ok(res.into());
            }

            inner.call(req).await
        })
    }
}

#[tokio::main]
async fn main() {
    let app = Router::new()
        .route("/balance", get(|| async { "balance" }))
        .layer(RateLimitLayer::new());

    axum::Server::bind(&"0.0.0.0:3000".parse().unwrap())
        .serve(app.into_make_service())
        .await
        .unwrap();
}

このコードでは、RateLimiter を Tower ミドルウェアとしてラップし、「同じ client_id からのリクエスト数が一定のペースを超えた場合に 429」を返す。実運用では X-RateLimit-RemainingRetry-After ヘッダを付け、クライアントがリトライ制御しやすいようにする。
複数ノードや日次クォータは、Redis 等の外部ストアや API Gateway の機能併用が現実的。

3-3. フェイルセーフ:429だけ出し続けない工夫

バックエンドが落ちているとき、レート制限だけ元気に動くと 429 が増えるだけ。例えば以下を用意。

  • バックエンドのヘルスが悪いときは「ソフト」モード(ログのみ)
  • 監査用ログに「いつ、どの client_id を何回止めたか」
  • 重要な内部システム向け client_id は別枠上限(優遇枠)

4. 構造化ログと分散トレース:監査できる・直せるAPIにする

4-1. JSONログを前提に設計する

金融APIでは「いつ・誰が・何をしたか」をあとから追えることが大事。
そのために構造化ログ(JSON形式)で出力しておく。

Rust では tracingtracing-subscriber が定番。

use tracing::{info, Level};
use tracing_subscriber::{fmt, EnvFilter};

fn init_tracing() {
    // JSONフォーマットでログを出す
    let subscriber = fmt::Subscriber::builder()
        .with_env_filter(EnvFilter::from_default_env())
        .json()
        .with_current_span(true)
        .with_span_list(true)
        .finish();

    tracing::subscriber::set_global_default(subscriber)
        .expect("setting default subscriber failed");
}

fn main() {
    init_tracing();

    info!(target: "startup", "service started");
}

APIでは最低限次のフィールドがあると便利。

  • timestamp
  • level(info/warn/error)
  • request_id(相関ID)
  • client_id(mTLSから取り出したID)
  • outcome(success / failure)
  • latency_ms(処理時間)

4-2. request_id を付与し、ヘッダで伝播する

マイクロサービス構成では経路追跡が必要。入口でrequest_id(相関ID)を発行し、ヘッダで下流に伝える。

use axum::extract::Request;
use tower::{Layer, Service};
use http::{HeaderValue, header::HeaderName};
use uuid::Uuid;
use std::task::{Context, Poll};
use futures_util::future::BoxFuture;
use tracing::info;

static REQUEST_ID_HEADER: &str = "x-request-id";

#[derive(Clone)]
struct RequestIdLayer;

impl<S> Layer<S> for RequestIdLayer {
    type Service = RequestIdMiddleware<S>;
    fn layer(&self, inner: S) -> Self::Service {
        RequestIdMiddleware { inner }
    }
}

#[derive(Clone)]
struct RequestIdMiddleware<S> {
    inner: S,
}

impl<S, B> Service<Request<B>> for RequestIdMiddleware<S>
where
    S: Service<Request<B>, Response = axum::response::Response> + Clone + Send + 'static,
    S::Future: Send + 'static,
{
    type Response = S::Response;
    type Error = S::Error;
    type Future = BoxFuture<'static, Result<Self::Response, Self::Error>>;

    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
        self.inner.poll_ready(cx)
    }

    fn call(&mut self, mut req: Request<B>) -> Self::Future {
        let mut inner = self.inner.clone();

        // 既に上流から request_id が来ていたらそれを使う
        let request_id = req
            .headers()
            .get(REQUEST_ID_HEADER)
            .and_then(|v| v.to_str().ok())
            .map(|s| s.to_string())
            .unwrap_or_else(|| Uuid::new_v4().to_string());

        // ヘッダにセットし直す
        req.headers_mut().insert(
            HeaderName::from_static(REQUEST_ID_HEADER),
            HeaderValue::from_str(&request_id).unwrap(),
        );

        // ログのコンテキストに含める
        info!(request_id = %request_id, "start request");

        BoxFuture::pin(async move {
            let mut res = inner.call(req).await?;
            // レスポンスにも request_id を付けて返す
            res.headers_mut().insert(
                HeaderName::from_static(REQUEST_ID_HEADER),
                HeaderValue::from_str(&request_id).unwrap(),
            );
            Ok(res)
        })
    }
}

同じ x-request-id をリクエスト・レスポンス両方に載せることで、問い合わせ時にログをひとまとめに追える。

4-3. OpenTelemetry でトレースも取る

tracing-opentelemetry を使ってトレースをAPM(DatadogやTempo等)に送る。

use opentelemetry::sdk::export::trace::stdout;
use opentelemetry::sdk::trace as sdktrace;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::Registry;
use tracing_opentelemetry::OpenTelemetryLayer;

fn init_tracing_with_otlp() {
    let tracer = sdktrace::TracerProvider::builder()
        .with_simple_exporter(stdout::Exporter::default())
        .build()
        .tracer("my-service");

    let otel_layer = OpenTelemetryLayer::new(tracer);

    let subscriber = Registry::default().with(otel_layer);

    tracing::subscriber::set_global_default(subscriber).unwrap();
}

tracing の span が OTLP として外部APMに送られる構成にできる。

5. ルーティングとミドルウェア設計:横断関心事を漏らさない

5-1. 典型エンドポイントとヘルスチェック

「小さめの金融API」を想定したエンドポイント構成。

  • GET /balance:残高の取得
  • POST /payments:支払いの実行(Idempotency-Key必須
  • GET /healthz:ヘルスチェック(LBや監視用、mTLSなしでもOK)

ポイントは、/healthz だけ「認証なし・mTLSなし」で叩けるようにすること。LBやKubernetesのヘルスチェックがシンプルになる。

5-2. ミドルウェアの積み順

ミドルウェアの順番は重要。典型は以下。

  1. TLS終端(mTLS検証)
  2. request_id付与(ヘッダ+ログ)
  3. レート制限(client_idベース)
  4. 認可/スコープ検証
  5. 監査ログ(リクエスト+レスポンス)
  6. ハンドラ(ビジネスロジック)

Axumでのルータ定義イメージ。

use axum::{routing::{get, post}, Router};
use tower::ServiceBuilder;
use tower_http::trace::TraceLayer;

fn app() -> Router {
    let middlewares = ServiceBuilder::new()
        .layer(TraceLayer::new_for_http()) // リクエストのトレース
        .layer(RequestIdLayer)             // request_id 付与
        .layer(RateLimitLayer::new());     // レート制限

    Router::new()
        .route("/healthz", get(|| async { "ok" }))
        .route("/balance", get(get_balance))
        .route("/payments", post(create_payment))
        .layer(middlewares)
}

async fn get_balance() -> &'static str {
    "balance"
}

async fn create_payment() -> &'static str {
    "payment created"
}

ServiceBuilder でミドルウェアをまとめて定義し、Router 全体に適用して「全エンドポイントが同じ mTLS→レート制限→ログ の流れ」を通るようにする。エラーの整形(Problem Details JSONなど)はできるだけ一箇所のミドルウェアで。

5-3. テスト容易性:ビジネスロジックは純関数寄りに

HTTP層は依存が多くなるが、ビジネスロジックは「引数→戻り値」な純関数に寄せるとテストが容易。

struct PaymentRequest {
    amount: i64,
    currency: String,
    from_account: String,
    to_account: String,
}

struct PaymentResult {
    payment_id: String,
    status: String,
}

trait PaymentStore {
    fn reserve_balance(&self, req: &PaymentRequest) -> anyhow::Result<()>;
    fn commit_payment(&self, req: &PaymentRequest) -> anyhow::Result<PaymentResult>;
}

fn process_payment<S: PaymentStore>(
    store: &S,
    req: &PaymentRequest,
) -> anyhow::Result<PaymentResult> {
    store.reserve_balance(req)?;
    store.commit_payment(req)
}

PaymentStore トレイトに依存させることで、テスト時はモック実装に差し替え可能。HTTPハンドラはこの関数を呼ぶだけにして「ロジックの単体テスト」と「HTTPの結合テスト」を分離。

6. テストと検証:mTLS/負荷/カオスを小さく回す

6-1. mTLS 統合テスト(reqwest)

reqwest クライアントにクライアント証明書を載せ、実際にハンドシェイクを通す。

use reqwest::Client;
use reqwest::tls;

#[tokio::test]
async fn test_mtls_success() {
    let cert = tls::Certificate::from_pem(include_bytes!("../client.crt")).unwrap();
    let key = tls::Identity::from_pem(include_bytes!("../client.pem")).unwrap();

    let client = Client::builder()
        .add_root_certificate(cert)
        .identity(key)
        .build()
        .unwrap();

    let res = client
        .get("https://localhost:8443/healthz")
        .send()
        .await
        .unwrap();

    assert!(res.status().is_success());
}

正しい証明書で 200、無効な証明書で 4xx/TLSエラーになることを確認し、「mTLS が効いているか」を検証。

6-2. レート制限と冪等性のテスト

  • レート制限はやや多めの並列リクエストを投げ、429 の割合や Retry-After を確認(k6/bombardier 等で 100〜1,000 req)。
  • 冪等性は、同じ Idempotency-KeyPOST /payments を複数回投げ、同一レスポンス(同一 payment_id)を確認。

6-3. 観測とSLOの検証

CI でログ・トレースの必須情報を軽くチェック。

  • JSONログに request_id / client_id / latency_ms
  • レート制限発生時に rate_limit.hit = true のような属性が span に付く
  • mTLS失敗率(ハンドシェイクエラー数)がダッシュボードで見える

ここまで整えると、「障害時に数分で状況把握」に近づく。

7. まとめ

  • Rust(Axum/Actix)で金融系APIを作るときの最小セットとして、
  • mTLS(相互TLS)
  • IP+クライアントIDの二段レート制限
  • 構造化ログ+分散トレース
    を揃えると、壊れにくく・監査しやすいAPIになる。
  • mTLSで入口の身元保証、レート制限で通過量の制御、構造化ログとトレースで事後の説明責任——この3本柱を意識すると設計がぶれにくい。
  • コードはシンプルに、ただし証明書運用・SLO監視・ログ設計など運用を厚くするのが金融ドメインでは重要。

まずは小さなAPI(/balance など)から、mTLS+レート制限+構造化ログを試し、そこから少しずつ範囲を広げていくのがおすすめ。

参考URL