SPIRE 系列之四:实战 Lab — 用零信任身份替代数据库密码分发

Posted on 日 26 4月 2026 in Journal

SPIRE 系列之四:实战 Lab — 用零信任身份替代数据库密码分发

SPIRE 系列第 4 篇 — 前三篇讲概念、架构和安全,这一篇动手做个小实验:应用启动时没有数据库密码,只靠 SPIFFE 身份向 Secret Server 证明“我是谁”。纸上得来终觉浅,配置跑一遍,很多概念就落地了。

系列导航: - 01:从 Workload Identity 到 Zero Trust - 02:SPIRE 架构深度解析 - 03:安全性分析与加固清单 - 04:实战 Lab:用零信任身份替代数据库密码分发

1. 问题:密码分发的困境

一个 Python 订单服务需要连接 PostgreSQL,密码怎么给它?这个问题看起来小,其实是很多系统安全债的起点。

传统做法:
  环境变量 DB_PASSWORD=secret123  → docker inspect 可看
  配置文件 config.yaml            → git 泄露风险
  Vault + Token                  → Token 本身又是一个 secret

核心矛盾:要证明“我是合法的服务”才能拿到密码,但证明身份本身又需要一个凭证。这就是 Secret Zero 问题,像鸡生蛋、蛋生鸡,绕不好就全是秘密。

2. SPIRE 的解法:身份来自"你是谁",而非"你知道什么"

┌─────────────────────────────────────────────────────┐
│                    信任链                             │
│                                                      │
│  Linux 内核 (UID/PID/cgroup)                         │
│       ↓ 内核级证据,无法伪造                           │
│  SPIRE Agent (节点常驻,已向 Server 证明身份)          │
│       ↓ Unix Domain Socket (仅本机进程可访问)          │
│  Order Service (UID=1000 的进程)                      │
│       ↓ 拿到 JWT-SVID (短期身份令牌)                  │
│  Secret Server (验证 JWT 签名 + SPIFFE ID)            │
│       ↓ 验证通过                                      │
│  返回数据库密码 (仅在内存中,不落盘)                    │
└─────────────────────────────────────────────────────┘

关键点:Order Service 启动时不需要任何密码、Token、证书。它的身份由 SPIRE Agent 通过操作系统内核信息自动证明。

3. 完整架构与组件

┌──────────────────────────────────────────────────────────────┐
│  Docker Compose 环境                                         │
│                                                              │
│  ┌─────────┐    ┌─────────┐    ┌──────────┐                  │
│  │ Postgres│    │  SPIRE  │───▶│  SPIRE   │                  │
│  │  :5432  │    │ Server  │    │ Agent    │                  │
│  └────▲────┘    │  :8081  │    │ (node-1) │                  │
│       │         └─────────┘    └────┬─────┘                  │
│       │                             │UDS                     │
│       │          ┌──────────────────┴────────┐               │
│       │          ▼                           ▼               │
│       │    ┌──────────┐            ┌──────────────┐          │
│       │    │  Order   │──JWT-SVID─▶│   Secret     │          │
│       │    │ Service  │◀──密码─────│   Server     │          │
│       │    │ UID=1000 │            │   UID=1001   │          │
│       └────│  :8080   │            │   :8443      │          │
│            └──────────┘            └──────────────┘          │
└──────────────────────────────────────────────────────────────┘

4. Provisioning:服务如何注册到 SPIRE

这一节最容易被忽略。SPIRE 不是看见进程就发证,它需要你先告诉 Server:“哪些工作负载应该获得什么身份”。登记错了,后面验证再严也白搭。

4.1 注册流程全景

Provisioning 三步曲:

Step 1: 注册 Node(节点)
  管理员 → spire-server entry create → "Agent 节点 node-1 是可信的"

Step 2: 注册 Workload(工作负载)
  管理员 → spire-server entry create → "node-1 上 UID=1000 的进程是 order-service"
  管理员 → spire-server entry create → "node-1 上 UID=1001 的进程是 secret-server"

Step 3: 运行时自动颁发身份
  Agent 观察到 UID=1000 的进程连接 → 自动颁发 JWT-SVID
  无需应用做任何配置

4.2 Node Attestation — Agent 如何向 Server 证明自己

Agent 首次启动时,必须向 Server 证明"我是合法节点":

# Agent 启动时,使用 join_token 方式向 Server 注册
# 生产环境通常用 AWS IID、GCP IIT、K8s PSAT 等自动方式

# Step 1: Server 端生成一次性 join token
docker exec spire-server \
  spire-server token generate -spiffeID spiffe://example.org/agent/node-1
# 输出: Token: 3a7b9c...(一次性,用后即焚)

# Step 2: Agent 启动时使用此 token
# agent.conf 中配置:
# plugins {
#   NodeAttestor "join_token" {
#     plugin_data {}
#   }
# }
# 启动命令带上 -joinToken 3a7b9c...

安全性:Join Token 是一次性的,使用后立即失效。Agent 获得节点级 SVID 后,后续通过证书轮换保持身份,不再需要 token。

4.3 Workload Registration — 告诉 SPIRE 谁是谁

# === 注册 Order Service ===
# 含义:"在 node-1 上,UID 为 1000 的进程,身份是 order-service"
docker exec spire-server \
  spire-server entry create \
    -parentID spiffe://example.org/agent/node-1 \
    -spiffeID spiffe://example.org/service/order-service \
    -selector unix:uid:1000 \
    -dns order-service \
    -ttl 300

# === 注册 Secret Server ===
# 含义:"在同一个演示节点 node-1 上,UID 为 1001 的进程,身份是 secret-server"
docker exec spire-server \
  spire-server entry create \
    -parentID spiffe://example.org/agent/node-1 \
    -spiffeID spiffe://example.org/service/secret-server \
    -selector unix:uid:1001 \
    -dns secret-server \
    -ttl 300

4.4 Selector 详解 — SPIRE 如何识别工作负载

Selector 是 SPIRE 识别"谁是谁"的核心机制:

Selector 类型 示例 识别依据 安全强度
unix:uid unix:uid:1000 进程的 Linux UID ⭐⭐⭐
unix:gid unix:gid:1000 进程的 Linux GID ⭐⭐⭐
unix:path unix:path:/usr/bin/app 可执行文件路径 ⭐⭐⭐⭐
unix:sha256 unix:sha256:abc123... 二进制文件哈希 ⭐⭐⭐⭐⭐
k8s:pod-label k8s:pod-label:app:order K8s Pod 标签 ⭐⭐⭐
k8s:sa k8s:sa:order-sa K8s ServiceAccount ⭐⭐⭐⭐
docker:label docker:label:com.app:order Docker 容器标签 ⭐⭐⭐

多 Selector 组合(AND 逻辑,更安全):

spire-server entry create \
  -parentID spiffe://example.org/agent/node-1 \
  -spiffeID spiffe://example.org/service/order-service \
  -selector unix:uid:1000 \
  -selector unix:path:/app/order_service.py \
  -selector unix:sha256:$(sha256sum /app/order_service.py | cut -d' ' -f1)

4.5 运行时身份颁发 — 自动发生,应用无感

注册完成后,当 Order Service 进程连接 SPIRE Agent 的 Unix Domain Socket 时:

Order Service (PID=42, UID=1000)
    │
    │ connect(/run/spire/agent.sock)
    ▼
SPIRE Agent
    │ 1. 通过 SO_PEERCRED 获取 PID=42
    │ 2. 读取 /proc/42/status → UID=1000
    │ 3. 读取 /proc/42/exe → /app/order_service.py
    │ 4. 匹配注册条目:unix:uid:1000 → order-service ✓
    │ 5. 签发 JWT-SVID(或从缓存返回)
    ▼
返回 JWT-SVID:
  sub: spiffe://example.org/service/order-service
  aud: ["secret-server"]
  exp: 1700000300 (5分钟后)

整个过程应用代码只需一行

jwt_svid = workload_client.fetch_jwt_svid(audiences=["secret-server"])

4.6 Provisioning 在不同环境的实现

环境 Node Attestation Workload Selector 自动化方式
开发/Docker Join Token unix:uid setup.sh 脚本,通常一个演示 Agent 承载多个进程
AWS EC2 AWS IID (自动) unix:uid + unix:path Terraform
Kubernetes K8s PSAT (自动) k8s:sa + k8s:ns Helm + CRD (SPIRE Controller Manager)
裸金属 X.509 证书 unix:sha256 Ansible

K8s 生产环境示例(使用 CRD 自动注册):

# 部署了 SPIRE Controller Manager 后,只需创建 CRD
apiVersion: spire.spiffe.io/v1alpha1
kind: ClusterSPIFFEID
metadata:
  name: order-service
spec:
  spiffeIDTemplate: "spiffe://{{ .TrustDomain }}/ns/{{ .PodMeta.Namespace }}/sa/{{ .PodSpec.ServiceAccountName }}"
  podSelector:
    matchLabels:
      app: order-service
  namespaceSelector:
    matchLabels:
      environment: production

这样新 Pod 部署时 自动注册身份,无需手动执行 spire-server entry create

5. 可运行代码

5.1 docker-compose.yaml

version: "3.8"

services:
  # --- 数据库 ---
  postgres:
    image: postgres:15-alpine
    environment:
      POSTGRES_DB: orders
      POSTGRES_USER: order_user
      # 这个密码只有 postgres 和 secret-server 知道
      # order-service 永远不会直接接触到它
      POSTGRES_PASSWORD: "super-secret-db-pass-2024"
    ports: ["5432:5432"]
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U order_user -d orders"]
      interval: 5s
      retries: 5

  # --- SPIRE Server ---
  spire-server:
    image: ghcr.io/spiffe/spire-server:1.9.1
    command: ["-config", "/opt/spire/conf/server.conf"]
    volumes:
      - ./server/server.conf:/opt/spire/conf/server.conf:ro
      - spire-server-data:/opt/spire/data
    ports: ["8081:8081"]
    healthcheck:
      test: ["CMD", "spire-server", "healthcheck"]
      interval: 5s
      retries: 10

  # --- SPIRE Agent ---
  spire-agent:
    image: ghcr.io/spiffe/spire-agent:1.9.1
    command: ["-config", "/opt/spire/conf/agent.conf"]
    volumes:
      - ./agent/agent.conf:/opt/spire/conf/agent.conf:ro
      - spire-agent-socket:/run/spire
      - /proc:/proc:ro          # 读取进程信息用于 workload attestation
    depends_on:
      spire-server:
        condition: service_healthy
    privileged: true              # 需要读取 /proc/<pid>/status

  # --- Secret Server (UID=1001) ---
  secret-server:
    build:
      context: ./apps
      args:
        APP_UID: 1001
    command: ["python", "secret_server.py"]
    user: "1001"
    environment:
      # Secret Server 是唯一知道数据库密码的服务
      DB_PASSWORD: "super-secret-db-pass-2024"
      SPIRE_AGENT_SOCKET: "/run/spire/agent.sock"
    volumes:
      - spire-agent-socket:/run/spire:ro
    ports: ["8443:8443"]
    depends_on: [spire-agent]

  # --- Order Service (UID=1000) ---
  # 注意:这里没有任何密码相关的环境变量!
  order-service:
    build:
      context: ./apps
      args:
        APP_UID: 1000
    command: ["python", "order_service.py"]
    user: "1000"
    environment:
      SPIRE_AGENT_SOCKET: "/run/spire/agent.sock"
      SECRET_SERVER_URL: "http://secret-server:8443"
    volumes:
      - spire-agent-socket:/run/spire:ro
    ports: ["8080:8080"]
    depends_on: [spire-agent, postgres]

volumes:
  spire-server-data:
  spire-agent-socket:

5.2 SPIRE Server 配置

# server/server.conf
server {
    bind_address = "0.0.0.0"
    bind_port = "8081"
    trust_domain = "example.org"
    data_dir = "/opt/spire/data/server"
    log_level = "INFO"
    ca_ttl = "24h"
    default_jwt_svid_ttl = "5m"
}

plugins {
    DataStore "sql" {
        plugin_data {
            database_type = "sqlite3"
            connection_string = "/opt/spire/data/server/datastore.sqlite3"
        }
    }
    KeyManager "memory" {
        plugin_data {}
    }
    NodeAttestor "join_token" {
        plugin_data {}
    }
}

5.3 SPIRE Agent 配置

# agent/agent.conf
agent {
    data_dir = "/opt/spire/data/agent"
    log_level = "INFO"
    server_address = "spire-server"
    server_port = "8081"
    socket_path = "/run/spire/agent.sock"
    trust_domain = "example.org"
    insecure_bootstrap = true   # 仅用于演示,生产环境应使用 trust_bundle_path
}

plugins {
    KeyManager "memory" {
        plugin_data {}
    }
    NodeAttestor "join_token" {
        plugin_data {}
    }
    WorkloadAttestor "unix" {
        plugin_data {
            discover_workload_path = true
        }
    }
}

5.4 应用 Dockerfile

# apps/Dockerfile
FROM python:3.11-slim

ARG APP_UID=1000
RUN useradd -u ${APP_UID} -m appuser

WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY *.py .

USER ${APP_UID}

5.5 依赖文件

# apps/requirements.txt
PyJWT[crypto]==2.8.0
cryptography==42.0.0
requests==2.31.0
psycopg2-binary==2.9.9
flask==3.0.0

5.6 Secret Server — 验证身份后才给密码

# apps/secret_server.py
"""
Secret Server: 持有数据库密码,仅在验证调用方 SPIFFE 身份后才返回。
"""
import os
import json
import logging
import urllib.request
from flask import Flask, request, jsonify
import jwt

app = Flask(__name__)
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
log = logging.getLogger("secret-server")

DB_PASSWORD = os.environ["DB_PASSWORD"]
TRUST_DOMAIN = "example.org"

# 允许获取密码的 SPIFFE ID 白名单
ALLOWED_SPIFFE_IDS = {
    f"spiffe://{TRUST_DOMAIN}/service/order-service",
}

def get_jwks():
    """从 SPIRE Agent 获取 JWKS 公钥(用于验证 JWT-SVID 签名)"""
    # 生产环境从 SPIRE Agent Workload API 获取
    # 演示环境从 SPIRE Server 的 bundle endpoint 获取
    url = "http://spire-server:8081/keys"
    resp = urllib.request.urlopen(url)
    return json.loads(resp.read())

@app.route("/secret/db-password", methods=["GET"])
def get_db_password():
    # --- Step 1: 提取 JWT-SVID ---
    auth_header = request.headers.get("Authorization", "")
    if not auth_header.startswith("Bearer "):
        log.warning("请求缺少 Bearer token")
        return jsonify({"error": "missing token"}), 401

    token = auth_header.split(" ", 1)[1]

    # --- Step 2: 验证 JWT 签名 ---
    try:
        jwks = get_jwks()
        # 解码 JWT header 获取 kid
        header = jwt.get_unverified_header(token)
        kid = header.get("kid")

        # 从 JWKS 中找到对应的公钥
        public_key = None
        for key_data in jwks.get("keys", []):
            if key_data.get("kid") == kid:
                public_key = jwt.algorithms.RSAAlgorithm.from_jwk(json.dumps(key_data))
                break

        if not public_key:
            log.warning(f"未找到 kid={kid} 对应的公钥")
            return jsonify({"error": "unknown signing key"}), 401

        # 验证签名、过期时间、audience
        claims = jwt.decode(
            token,
            public_key,
            algorithms=["RS256", "ES256"],
            audience="secret-server",
        )
    except jwt.ExpiredSignatureError:
        log.warning("JWT-SVID 已过期")
        return jsonify({"error": "token expired"}), 401
    except jwt.InvalidTokenError as e:
        log.warning(f"JWT 验证失败: {e}")
        return jsonify({"error": "invalid token"}), 401

    # --- Step 3: 检查 SPIFFE ID 白名单 ---
    spiffe_id = claims.get("sub", "")
    if spiffe_id not in ALLOWED_SPIFFE_IDS:
        log.warning(f"SPIFFE ID 不在白名单中: {spiffe_id}")
        return jsonify({"error": "unauthorized identity"}), 403

    # --- Step 4: 身份验证通过,返回密码 ---
    log.info(f"✅ 身份验证通过: {spiffe_id} → 返回数据库密码")
    return jsonify({
        "db_host": "postgres",
        "db_port": 5432,
        "db_name": "orders",
        "db_user": "order_user",
        "db_password": DB_PASSWORD,
        "ttl_seconds": 300,       # 建议客户端 5 分钟后重新获取
    })

@app.route("/health", methods=["GET"])
def health():
    return jsonify({"status": "ok"})

if __name__ == "__main__":
    log.info("Secret Server 启动,监听 :8443")
    app.run(host="0.0.0.0", port=8443)

5.7 Order Service — 零密码启动

# apps/order_service.py
"""
Order Service: 启动时没有任何密码。
通过 SPIRE 获取身份,再用身份从 Secret Server 获取数据库密码。
"""
import os
import time
import json
import socket
import logging
import threading
import psycopg2
import requests
from flask import Flask, jsonify

app = Flask(__name__)
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
log = logging.getLogger("order-service")

SECRET_SERVER_URL = os.environ.get("SECRET_SERVER_URL", "http://secret-server:8443")
SPIRE_AGENT_SOCKET = os.environ.get("SPIRE_AGENT_SOCKET", "/run/spire/agent.sock")

# 数据库连接(运行时动态获取密码后建立)
db_conn = None
db_config = None

def fetch_jwt_svid_from_agent(audience="secret-server"):
    """
    通过 Unix Domain Socket 从 SPIRE Agent 获取 JWT-SVID。
    这是 SPIFFE Workload API 的核心调用。

    Agent 会:
    1. 通过 SO_PEERCRED 获取调用进程的 PID
    2. 读取 /proc/<PID>/status 获取 UID
    3. 匹配注册条目
    4. 签发或返回缓存的 JWT-SVID
    """
    import urllib.request
    import urllib.parse

    # SPIFFE Workload API 通过 Unix Domain Socket 通信
    # 使用 HTTP over UDS
    params = urllib.parse.urlencode({"audience": audience})
    url = f"http://localhost/v1/auth/jwt-svids?{params}"

    # 创建 Unix Socket 连接
    class UDSHandler(urllib.request.HTTPHandler):
        def http_open(self, req):
            return self.do_open(UDSConnection, req)

    class UDSConnection:
        def __init__(self, host, timeout=10):
            self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
            self.sock.connect(SPIRE_AGENT_SOCKET)
            self.sock.settimeout(timeout)

        def request(self, method, url, body=None, headers={}):
            path = url.split("localhost", 1)[1] if "localhost" in url else url
            req_str = f"{method} {path} HTTP/1.1\r\nHost: localhost\r\n"
            # SPIFFE Workload API 要求设置安全 header
            req_str += "Security: true\r\n"
            for k, v in headers.items():
                req_str += f"{k}: {v}\r\n"
            req_str += "\r\n"
            self.sock.sendall(req_str.encode())

            # 读取响应
            response = b""
            while True:
                chunk = self.sock.recv(4096)
                if not chunk:
                    break
                response += chunk
            return response

    # 简化版:直接用 requests-unixsocket 或手动 socket 调用
    # 这里用最直观的方式展示原理
    try:
        sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
        sock.connect(SPIRE_AGENT_SOCKET)
        sock.settimeout(10)

        # 构造 HTTP 请求
        http_req = (
            f"GET /v1/auth/jwt-svids?audience={audience} HTTP/1.1\r\n"
            f"Host: localhost\r\n"
            f"Security: true\r\n"      # SPIFFE Workload API 必需的 header
            f"Connection: close\r\n"
            f"\r\n"
        )
        sock.sendall(http_req.encode())

        # 读取响应
        response = b""
        while True:
            chunk = sock.recv(4096)
            if not chunk:
                break
            response += chunk
        sock.close()

        # 解析 HTTP 响应 body
        body = response.split(b"\r\n\r\n", 1)[1]
        data = json.loads(body)
        jwt_svid = data["svids"][0]["svid"]
        spiffe_id = data["svids"][0]["spiffe_id"]
        log.info(f"✅ 获取 JWT-SVID 成功: {spiffe_id}")
        return jwt_svid

    except Exception as e:
        log.error(f"❌ 获取 JWT-SVID 失败: {e}")
        raise

def fetch_db_password():
    """用 JWT-SVID 从 Secret Server 获取数据库密码"""
    jwt_token = fetch_jwt_svid_from_agent(audience="secret-server")

    resp = requests.get(
        f"{SECRET_SERVER_URL}/secret/db-password",
        headers={"Authorization": f"Bearer {jwt_token}"},
        timeout=5,
    )

    if resp.status_code == 200:
        config = resp.json()
        log.info(f"✅ 获取数据库密码成功 (TTL={config['ttl_seconds']}s)")
        return config
    else:
        log.error(f"❌ 获取密码失败: {resp.status_code} {resp.text}")
        raise RuntimeError(f"Secret Server 返回 {resp.status_code}")

def connect_db():
    """使用动态获取的密码连接数据库"""
    global db_conn, db_config
    db_config = fetch_db_password()
    db_conn = psycopg2.connect(
        host=db_config["db_host"],
        port=db_config["db_port"],
        dbname=db_config["db_name"],
        user=db_config["db_user"],
        password=db_config["db_password"],
    )
    log.info("✅ 数据库连接成功")

def password_refresh_loop():
    """后台线程:定期刷新数据库密码"""
    while True:
        time.sleep(240)  # 每 4 分钟刷新
        try:
            log.info("🔄 刷新数据库密码...")
            connect_db()
        except Exception as e:
            log.error(f"密码刷新失败: {e}")

@app.route("/orders", methods=["GET"])
def list_orders():
    try:
        cur = db_conn.cursor()
        cur.execute("SELECT 1")  # 简单查询验证连接
        return jsonify({"status": "ok", "message": "database connected", "result": cur.fetchone()})
    except Exception as e:
        return jsonify({"error": str(e)}), 500

@app.route("/health", methods=["GET"])
def health():
    return jsonify({"status": "ok", "has_db": db_conn is not None})

if __name__ == "__main__":
    log.info("=" * 60)
    log.info("Order Service 启动")
    log.info("注意:启动参数中没有任何密码!")
    log.info("=" * 60)

    # 启动时动态获取密码并连接数据库
    max_retries = 30
    for i in range(max_retries):
        try:
            connect_db()
            break
        except Exception as e:
            log.warning(f"等待依赖服务就绪... ({i+1}/{max_retries}): {e}")
            time.sleep(2)
    else:
        log.error("无法连接数据库,退出")
        exit(1)

    # 启动后台密码刷新线程
    threading.Thread(target=password_refresh_loop, daemon=True).start()

    log.info("Order Service 就绪,监听 :8080")
    app.run(host="0.0.0.0", port=8080)

6. Provisioning 脚本 — 一键注册所有身份

#!/bin/bash
# setup.sh — 完整的 Provisioning 脚本
set -euo pipefail

echo "=========================================="
echo " SPIRE Provisioning — 身份注册"
echo "=========================================="

SPIRE_SERVER="docker exec spire-server spire-server"

# --- Step 1: 等待 SPIRE Server 就绪 ---
echo ""
echo "[Step 1/4] 等待 SPIRE Server 就绪..."
for i in $(seq 1 30); do
    if $SPIRE_SERVER healthcheck 2>/dev/null; then
        echo "  ✅ SPIRE Server 就绪"
        break
    fi
    echo "  等待中... ($i/30)"
    sleep 2
done

# --- Step 2: 生成 Join Token 并启动 Agent ---
echo ""
echo "[Step 2/4] 为 Agent 生成 Join Token..."
JOIN_TOKEN=$($SPIRE_SERVER token generate \
    -spiffeID spiffe://example.org/agent/node-1 \
    -ttl 600 | awk '{print $2}')
echo "  ✅ Token 已生成(一次性,10分钟有效)"
echo "  ⚠️  此 Token 使用后立即失效,无法重放"

# Agent 使用此 token 启动(在 docker-compose 中已配置)
echo "  📝 Agent 启动命令:"
echo "     spire-agent run -joinToken $JOIN_TOKEN"

# --- Step 3: 注册工作负载身份 ---
echo ""
echo "[Step 3/4] 注册工作负载身份..."

# 注册 Order Service
echo ""
echo "  注册 Order Service:"
echo "    Parent:   spiffe://example.org/agent/node-1"
echo "    SPIFFE ID: spiffe://example.org/service/order-service"
echo "    Selector: unix:uid:1000"
$SPIRE_SERVER entry create \
    -parentID spiffe://example.org/agent/node-1 \
    -spiffeID spiffe://example.org/service/order-service \
    -selector unix:uid:1000 \
    -dns order-service \
    -ttl 300 \
    2>/dev/null || echo "  (条目可能已存在)"
echo "  ✅ Order Service 已注册"

# 注册 Secret Server
echo ""
echo "  注册 Secret Server:"
echo "    Parent:   spiffe://example.org/agent/node-1"
echo "    SPIFFE ID: spiffe://example.org/service/secret-server"
echo "    Selector: unix:uid:1001"
$SPIRE_SERVER entry create \
    -parentID spiffe://example.org/agent/node-1 \
    -spiffeID spiffe://example.org/service/secret-server \
    -selector unix:uid:1001 \
    -dns secret-server \
    -ttl 300 \
    2>/dev/null || echo "  (条目可能已存在)"
echo "  ✅ Secret Server 已注册"

# --- Step 4: 验证注册结果 ---
echo ""
echo "[Step 4/4] 验证注册结果..."
echo ""
$SPIRE_SERVER entry show
echo ""
echo "=========================================="
echo " ✅ Provisioning 完成!"
echo "=========================================="
echo ""
echo "已注册的信任关系:"
echo "  Linux Kernel (UID=1000) → SPIRE Agent → order-service 身份"
echo "  Linux Kernel (UID=1001) → SPIRE Agent → secret-server 身份"
echo ""
echo "现在 order-service 可以:"
echo "  1. 自动从 Agent 获取 JWT-SVID(无需密码)"
echo "  2. 用 JWT-SVID 向 secret-server 证明身份"
echo "  3. 获取数据库密码(仅在内存中)"
echo ""
echo "测试命令:"
echo "  curl http://localhost:8080/orders"

7. 安全性分析:为什么密码不会泄露

7.1 密码在哪里?不在哪里?

✅ 密码存在的地方(受控):
  1. PostgreSQL 内部                    — 数据库自身管理
  2. Secret Server 进程内存              — 唯一知道密码的服务
  3. Order Service 进程内存(临时)       — 获取后仅存内存,4分钟刷新

❌ 密码不存在的地方(消除攻击面):
  1. Order Service 的代码               — 代码中无任何密码
  2. Order Service 的配置文件            — 无 config.yaml
  3. Order Service 的环境变量            — docker inspect 看不到
  4. Order Service 的启动命令            — ps aux 看不到
  5. 网络传输中的明文                    — JWT 签名保护
  6. 任何磁盘文件                       — 全程内存操作
  7. 日志文件                           — 不记录密码
  8. Git 仓库                           — 代码中无密码

7.2 攻击者要突破需要什么?

攻击场景 1: 偷取 JWT-SVID
  ❌ JWT 5分钟过期
  ❌ audience 绑定为 "secret-server",不能用于其他服务
  ❌ 传输通过 Unix Domain Socket,不经过网络

攻击场景 2: 伪造 SPIFFE 身份
  ❌ 需要 SPIRE Server 的 CA 私钥才能签发有效 JWT
  ❌ CA 私钥仅在 Server 内存中

攻击场景 3: 冒充 Order Service
  ❌ 需要在同一节点上以 UID=1000 运行进程
  ❌ 如果加了 unix:sha256 selector,还需要相同的二进制哈希
  ❌ 即使冒充成功,拿到的密码 5 分钟后 Secret Server 可轮换

攻击场景 4: 中间人攻击
  ❌ Agent 通信走 Unix Domain Socket(不经过网络栈)
  ❌ Secret Server 验证 JWT 签名(无法篡改)

7.3 与传统方案对比

攻击面 环境变量 Vault+Token SPIRE
docker inspect 可见 ⚠️ 是 ⚠️ Token 可见 ✅ 无
Git 泄露风险 ⚠️ 高 ⚠️ Token 泄露 ✅ 无
日志意外打印 ⚠️ 可能 ⚠️ 可能 ✅ 无
凭证过期自动轮换 ❌ 不会 ⚠️ 需配置 ✅ 自动
Secret Zero 问题 ❌ 存在 ❌ Token 是 Secret Zero ✅ 消除
身份可伪造 ❌ 任何人可设置环境变量 ⚠️ 偷到 Token 即可 ✅ 内核级证据

8. 运行实验

# 1. 启动所有服务
cd spire-hands-on-lab/
docker-compose up -d

# 2. 执行 Provisioning(注册身份)
bash setup.sh

# 3. 测试正常调用
curl http://localhost:8080/orders
# 返回: {"status": "ok", "message": "database connected"}

# 4. 观察 Order Service 日志 — 看到完整的身份获取流程
docker logs order-service
# 输出:
# Order Service 启动
# 注意:启动参数中没有任何密码!
# ✅ 获取 JWT-SVID 成功: spiffe://example.org/service/order-service
# ✅ 获取数据库密码成功 (TTL=300s)
# ✅ 数据库连接成功

# 5. 确认 Order Service 容器中没有任何密码
docker exec order-service env | grep -i pass
# (无输出 — 环境变量中没有密码)

docker exec order-service cat /proc/1/cmdline | tr '\0' ' '
# python order_service.py(命令行中没有密码)

9. 总结

传统方式:                          SPIRE 方式:

代码写死密码 ──────────▶ 泄露      代码中零密码 ──────────▶ 安全
配置文件存密码 ─────────▶ 泄露      配置中零密码 ──────────▶ 安全
环境变量传密码 ─────────▶ 泄露      环境变量零密码 ────────▶ 安全
Vault Token ──────────▶ 又一个密码  身份来自内核 ──────────▶ 无需密码
人工轮换 ─────────────▶ 遗忘       自动轮换 ──────────────▶ 持续安全

SPIRE 的核心价值:把“你知道什么密码”变成“你是谁”。身份由操作系统和平台证据证明,再由 SPIRE 签发短期凭证。应用少拿一个长期密码,系统就少一个长期隐患。

当然,真实生产环境不会像这个 Lab 这么简单。你还要接入 KMS、PostgreSQL datastore、Kubernetes PSAT、CSI Driver、审计和告警。可是方向已经很清楚:先让工作负载拿到可信身份,再让权限围绕身份收敛。目的无他,少藏密码,多验证身份。

系列文章: - 第 1 篇:从 Workload Identity 到 Zero Trust - 第 2 篇:SPIRE 架构深度解析 - 第 3 篇:安全性分析与加固清单 - 第 4 篇:实战 Lab — 用零信任身份替代数据库密码分发(本文)


上一篇:SPIRE 系列之三:安全性分析与加固清单 — 信任链、攻击面与生产加固策略。