あなたのマイクロサービスはVPCを共有しているため、互いを信頼している。この信頼は環境的権限である。サービスを呼び出す許可は、暗号学的証明ではなく、ネットワークトポロジーによって与えられる。境界を突破した攻撃者は、その権限をすべて相続する。

サービスが環境的権限なしに認証できるかどうかは、間違いなく可能だ。もっと難しいのは、なぜあなたのプラットフォームがそれを裏切りのように感じさせるのかという問いである。

本番環境における環境的権限の実態

ほとんどのサービス間「認証」は、実際にはただのネットワーク分割に過ぎない。サービスはプライベートサブネット内、APIゲートウェイの背後、あるいはサービスメッシュを持つKubernetesクラスタ内に存在する。前提は単純だ。リクエストが内部IPから来ていれば、それは正当なものだとする。

このモデルは、境界が崩壊すると同時に崩壊する。侵害されたポッド、認証情報漏洩後の横移動、あるいはサプライチェーンインジェクションによって、攻撃者は信頼ゾーン内部に入り込む。その時点で、すべてのサービスに到達可能となり、ほとんどのサービスは身元証明を求めない。攻撃者はこれ以上の認証情報を盗む必要はない。彼らは単に、ネットワークの環境的権限を相続するだけだ。

サービス間の真の認証とは、呼び出し側が自分が誰であるかを証明し、呼び出される側がその証明を検証することを意味する。ネットワークは単なる輸送手段だ。それが認証情報であるべきではない。

ケイパビリティベースのサービス認証の仕組み

実用的なアプローチには二つある。暗号学的アイデンティティ(すべてのサービスが証明可能な名前を持つ)と、ケイパビリティトークン(すべてのリクエストが改ざん不可能でスコープされた認可を持つ)だ。

相互TLSによる暗号学的アイデンティティ

相互TLSでは、クライアントとサーバーの両方がX.509証明書を提示する。サーバーはリクエストを処理する前にクライアントの証明書を検証する。クライアントはデータを送信する前にサーバーの証明書を検証する。どちらの側も、IPアドレスのためだけに相手を信頼することはない。

インフラストラクチャの課題は証明書の配布だ。SPIFFEとSPIREは、場所ではなくプロパティから導出された暗号学的アイデンティティをすべてのワークロードに与えることで、これを解決する。payments-apiを実行しているポッドは、spiffe://prod.example.com/payments-apiのようなSPIFFE IDを取得する。証明書は短命で、自動的にローテーションされる。

Goでの実装例を以下に示す。

package main

import (
    "crypto/tls"
    "crypto/x509"
    "fmt"
    "log"
    "net/http"
    "os"
)

// loadClientTLS returns a config that verifies the server AND presents a client cert.
func loadClientTLS(caCert, clientCert, clientKey []byte) *tls.Config {
    pool := x509.NewCertPool()
    pool.AppendCertsFromPEM(caCert)

    cert, err := tls.X509KeyPair(clientCert, clientKey)
    if err != nil {
        log.Fatalf("failed to load client cert: %v", err)
    }

    return &tls.Config{
        Certificates: []tls.Certificate{cert},
        RootCAs:      pool,
        // Do not skip verification based on IP or DNS name alone.
        InsecureSkipVerify: false,
    }
}

func main() {
    caCert, _ := os.ReadFile("ca.crt")
    clientCert, _ := os.ReadFile("service.crt")
    clientKey, _ := os.ReadFile("service.key")

    client := &http.Client{
        Transport: &http.Transport{
            TLSClientConfig: loadClientTLS(caCert, clientCert, clientKey),
        },
    }

    resp, err := client.Get("https://orders-api.internal:8443/orders")
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println(resp.Status)
}

重要な行はInsecureSkipVerify: falseだ。これがなければ、あなたは環境的な信頼に戻ってしまう。これがあれば、クライアントはサーバーがCAによって署名された有効な証明書を提示しない限り接続を拒否する。サーバーもクライアントに対して同じことを行う。

スコープされた認可のためのケイパビリティトークン

証明書はアイデンティティを証明する。ケイパビリティは認可を証明する。多くのシステムでは、両方が必要だ。自分が誰であるかを証明し、次にその特定の操作が許可されていることを証明する。

ケイパビリティトークンは、アイデンティティ、操作、リソースを改ざん不可能なパッケージに結び付ける。サービスが発行し、サービスが検証する。クエリするための中央セッションストアは存在しない。

import hmac
import hashlib
import secrets
import time

SECRET = secrets.token_bytes(32)

def mint_service_capability(
    caller_id: str,
    service: str,
    action: str,
    resource: str,
    ttl_seconds: int = 300
) -> str:
    """Mint a time-bound capability for a specific service action."""
    expires = int(time.time()) + ttl_seconds
    payload = f"{caller_id}:{service}:{action}:{resource}:{expires}"
    sig = hmac.new(SECRET, payload.encode(), hashlib.sha256).hexdigest()[:24]
    return f"{payload}:{sig}"

def verify_service_capability(
    token: str,
    expected_service: str,
    expected_action: str,
    expected_resource: str
) -> bool:
    """Verify a capability token is unexpired and correctly scoped."""
    try:
        caller_id, svc, action, resource, expires, sig = token.rsplit(":", 5)
    except ValueError:
        return False

    if int(expires) < time.time():
        return False
    if svc != expected_service or action != expected_action or resource != expected_resource:
        return False

    payload = f"{caller_id}:{svc}:{action}:{resource}:{expires}"
    expected_sig = hmac.new(SECRET, payload.encode(), hashlib.sha256).hexdigest()[:24]
    return hmac.compare_digest(sig, expected_sig)

# Service A requests a capability to call Service B.
token = mint_service_capability("payments-api", "orders-api", "read", "order-123")

# Service B verifies the capability before handling the request.
if not verify_service_capability(token, "orders-api", "read", "order-123"):
    raise PermissionError("Invalid or expired capability")

このトークンは自己完結型で短命だ。サービスBは認可サーバーを呼び出す必要はない。HMACと有効期限を確認するだけだ。スコープは明確だ。このトークンは、orders-apiからorder-123を読み取るためだけに有効だ。

なぜこれが予想以上に難しいのか

技術は存在する。難しいのは運用だ。

信頼のブートストラップが最初の問題だ。すべてのサービスが証明書を必要とするなら、何者かがその証明書を発行する必要がある。その何者か、すなわち認証局やSPIREサーバーは、新たな標的となる。あなたは信頼を排除したわけではない。集中させただけだ。

失効の問題もある。サービスが侵害された場合、そのアイデンティティをどう無効化するか?mTLS証明書は短命にできるため、それは役立つが、特定の証明書を失効させるには、証明書失効リストの配布やOCSPの使用が依然として必要だ。ケイパビリティトークンはすぐに失効するため、被害は限定されるが、署名鍵が盗まれると攻撃者は無制限のトークンを発行できる。

パフォーマンスも懸念事項だ。TLSハンドシェイクはレイテンシを増やす。サイドカーを持つサービスメッシュでは、オーバーヘッドは通常許容範囲内だ(数ミリ秒だが)、高スループットの経路では、そのミリ秒が積み重なる。ケイパビリティトークンは認可サーバーへの往復を回避するが、大規模ではHMAC検証も無料ではない。

環境的権限が依然として妥当な場面

すべての内部呼び出しが暗号学的証明を必要とするわけではない。データベースクライアントと同じコンテナ内で実行される定期実行ジョブがlocalhostと通信するためにmTLSを必要とすることはない。ロードバランサーとノード間のヘルスチェックにケイパビリティトークンを必要とすることもない。

目標は環境的権限をゼロにすることではない。目標は、あなたの環境的権限がどこに存在し、その影響範囲が許容できるかどうかを知ることだ。200のサービスを持ち、内部認証がない共有VPCは、大きくて見えない影響範囲だ。ファイル権限0600のローカルUnixソケットは、小さくて明確な影響範囲だ。

サービス呼び出しから環境的権限を取り除く方法

初日からサービスメッシュを必要とするわけではない。機密データに触れるサービスや、信頼境界に位置するサービスから始めよ。

まず、代理人を特定せよ。どのサービスが他にはない特権を持っているか。間違ったサービスに呼び出された場合、どの内部APIが危険か。それらがあなたの候補だ。

次に、API層にmTLSを追加せよ。EnvoyやNGINXのようなリバースプロキシを使用している場合、アプリケーションコードを変更せずにそこでmTLSを終端できる。プロキシは、検証済みのクライアントアイデンティティを含むヘッダーをアップストリームに渡す。アップストリームはそのヘッダーを確認する。

第三に、トークンにスコープを設定せよ。すでにサービス認証にJWTを使用している場合、ペイロードにrole: serviceを入れるのをやめよ。代わりにallowed_services: ["orders-api"]allowed_resources: ["order:*"]を入れよ。認可を、操作と同じくらい具体的にせよ。

環境的権限なしのサービス認証に関するよくある質問

mTLSだけで十分か、ケイパビリティも必要か?

mTLSはアイデンティティを証明する。ケイパビリティは認可を証明する。彼らは異なる問題を解決する。有効な証明書を持つサービスでも、任意のデータを削除することを許可すべきではない。通信セキュリティとアイデンティティにはmTLSを、認可制御にはケイパビリティを使用せよ。

IstioやLinkerdのようなサービスメッシュはどうか?

サービスメッシュはポッド間のmTLSとアイデンティティを自動化する。すべてのサービスを書き換えずに環境的権限を排除する実用的な方法だ。メッシュは証明書のローテーション、アイデンティティの証明、トラフィックの暗号化を処理する。トレードオフは複雑性と、新たに保護する必要のあるコントロールプレーンだ。

これはサーバーレス関数でも機能するか?

はい、だがより難しい。サーバーレス関数は短命なため、証明書のローテーションとアイデンティティの証明は呼び出し時に行われる必要がある。クラウドプロバイダーはこのためにワークロードアイデンティティを提供している(AWS IAM Roles for Service Accounts、GCP Workload Identity)。これらはケイパビリティに似ている。関数は、包括的な認証情報ではなく、許可された操作にスコープされたトークンを受け取る。

サービスアカウントとケイパビリティの違いは何か?

サービスアカウントは、デフォルトで環境的権限を持つアイデンティティだ。サービスAがpayments-saとして実行される場合、通常はpayments-saに許可されたすべてのことを行うことができる。ケイパビリティは、特定のリソースに対する特定の操作にスコープされている。たとえケイパビリティが盗まれても、別の標的に対して再利用することはできない。

すでに持っている境界から始めよ

あなたのVPCは決してセキュリティ境界ではなかった。それは私たちがセキュリティ境界のふりをしていたネットワーク境界だった。環境的権限を排除することは、すべてのサービス呼び出しがその境界を越えるかのように扱うことを意味する。暗号学的アイデンティティとスコープされたケイパビリティがツールだ。難しいのは、古いモデルがセキュリティを装った利便性であったことを認めることだ。

一つの内部APIを選べ。クライアント証明書の検証を追加せよ。何が壊れるかを観察せよ。ほとんどの場合、壊れるものは、あなたのコードが知らずに前提としていた仮定だ。