在敏捷流程中嵌入Java构件的自动化签名与验证机制


我们团队的敏捷节奏是两周一个迭代,CI/CD流水线每天要跑上百次。最近一个季度的复盘会上,一个潜在风险被摆上了桌面:我们如何确保部署到生产环境的JAR包,就是CI服务器上经过完整测试、最终构建出来的那个?长久以来,我们依赖的是Artifactory的内置校验和以及看似隔离的网络环境,这在内部威胁和复杂的供应链攻击面前显得非常脆弱。有人提出在部署前手动比对SHA-256哈希,但这立刻被否决了——任何需要手动介入的卡点都是对敏捷交付的直接破坏。

这个痛点促使我们立项,目标是在不牺牲交付速度的前提下,为我们的构建产物建立一套可追溯、防篡改的信任链。我们的构想是,在CI流水线中自动地为每个构建成功的Java构件(JAR/WAR)生成一个不可否认的“身份证明”——也就是数字签名。

初步构想与技术选型决策

最初的构想很简单:用GPG签名。但很快我们发现了问题。在自动化的CI环境中管理GPG私钥是个大麻烦。将私钥以环境变量或Secret文件的形式注入到临时的CI Runner中,本身就带来了新的攻击面。一个常见的错误是,为了图方便,团队共享一个长期有效的GPG密钥,一旦泄露,后果不堪设想。

受社区Sigstore项目“keyless signing”理念的启发,我们决定探索一种更适合CI/CD场景的方案。完全自研一套基于OIDC和透明日志的系统过于复杂,不符合敏捷“小步快跑”的原则。因此,我们做了一个折中且务实的决策:利用云服务商提供的密钥管理服务(KMS)或IAM的短期凭证来执行签名操作。签名的密钥由云服务商管理,CI Runner通过扮演特定角色(Role)获得临时的签名权限。这样,静态的、长期的私钥就从我们的工作流中消失了。

我们的技术栈是Java,所以这套工具链也必须是Java原生的,以便无缝集成到现有的Maven/Gradle构建流程中。

最终的技术选型如下:

  1. 核心语言: Java 17。
  2. 密码学库: Bouncy Castle。虽然Java标准库提供了JCA/JCE,但Bouncy Castle在算法支持和灵活性上更胜一筹。
  3. 签名算法: SHA256withECDSA。相比RSA,ECDSA有更好的性能和更短的密钥长度。
  4. “身份证明”结构: 我们称之为“构件证明”(Artifact Attestation)。它是一个JSON对象,不仅包含构件本身的哈希,还包含了构建上下文信息,如Git Commit ID、构建环境标识、时间戳,以及最重要的——软件物料清单(SBOM)的哈希。这个证明文件本身将被签名。
  5. SBOM格式: CycloneDX XML。它是业界标准,生态工具支持良好。

步骤化实现:构建签名与验证的核心逻辑

整个项目被拆分成几个核心Java模块,可以打包成一个命令行工具,方便在CI脚本中调用。

1. 定义构件证明(Attestation)的数据模型

这是信任链的基石。一个清晰、全面的数据模型至关重要。我们使用Jackson库来处理JSON序列化。

// File: io/devsecops/attestation/ArtifactAttestation.java

package io.devsecops.attestation;

import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;

import java.time.Instant;
import java.util.Objects;

/**
 * 构件证明的数据模型.
 * 它包含了验证一个构件来源和完整性所需的所有元数据.
 */
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ArtifactAttestation {

    private static final ObjectMapper MAPPER = new ObjectMapper()
            .enable(SerializationFeature.INDENT_OUTPUT);

    @JsonProperty("schema_version")
    private final String schemaVersion = "1.0";

    @JsonProperty("timestamp")
    private final long timestamp;

    @JsonProperty("build_context")
    private final BuildContext buildContext;

    @JsonProperty("artifact_identity")
    private final ArtifactIdentity artifactIdentity;

    public ArtifactAttestation(BuildContext buildContext, ArtifactIdentity artifactIdentity) {
        this.timestamp = Instant.now().getEpochSecond();
        this.buildContext = Objects.requireNonNull(buildContext, "buildContext cannot be null");
        this.artifactIdentity = Objects.requireNonNull(artifactIdentity, "artifactIdentity cannot be null");
    }

    // Getters...

    public String toJson() throws JsonProcessingException {
        return MAPPER.writeValueAsString(this);
    }

    public static class BuildContext {
        @JsonProperty("builder_id")
        private final String builderId; // e.g., "jenkins-runner-abc-123"

        @JsonProperty("git_commit_hash")
        private final String gitCommitHash;

        public BuildContext(String builderId, String gitCommitHash) {
            this.builderId = builderId;
            this.gitCommitHash = gitCommitHash;
        }
        // Getters...
    }

    public static class ArtifactIdentity {
        @JsonProperty("artifact_sha256")
        private final String artifactSha256;

        @JsonProperty("sbom_sha256")
        private final String sbomSha256; // CycloneDX SBOM hash

        public ArtifactIdentity(String artifactSha256, String sbomSha256) {
            this.artifactSha256 = artifactSha256;
            this.sbomSha256 = sbomSha256;
        }
        // Getters...
    }
}

这个模型非常清晰。BuildContext 描述了“在哪”和“从哪”构建,而 ArtifactIdentity 则描述了构建“是什么”。

2. 实现核心签名服务 ArtifactSigner

这是流水线中“盖章”的环节。真实项目中,sign 方法内部会调用云KMS的API。为了让代码可独立运行和测试,我们先用本地生成的密钥对来模拟。

// File: io/devsecops/signer/ArtifactSigner.java

package io.devsecops.signer;

import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.*;
import java.util.Base64;
import org.bouncycastle.jce.provider.BouncyCastleProvider;

import io.devsecops.attestation.ArtifactAttestation;

/**
 * 负责对构件证明进行签名.
 * 真实项目中,PrivateKey应由KMS或Hardware Security Module管理.
 */
public class ArtifactSigner {

    private static final String SIGNATURE_ALGORITHM = "SHA256withECDSA";

    static {
        // 注册Bouncy Castle作为安全提供者
        Security.addProvider(new BouncyCastleProvider());
    }

    private final PrivateKey privateKey;

    public ArtifactSigner(PrivateKey privateKey) {
        this.privateKey = Objects.requireNonNull(privateKey, "Private key cannot be null");
    }

    /**
     * 对给定的证明对象进行签名.
     * @param attestation 构件证明
     * @return Base64编码的签名字符串
     * @throws GeneralSecurityException 如果签名过程中发生密码学错误
     * @throws IOException 如果无法将证明序列化为JSON
     */
    public String sign(ArtifactAttestation attestation) throws GeneralSecurityException, IOException {
        Signature ecdsaSign = Signature.getInstance(SIGNATURE_ALGORITHM, "BC");
        ecdsaSign.initSign(privateKey);

        byte[] attestationBytes = attestation.toJson().getBytes(StandardCharsets.UTF_8);
        ecdsaSign.update(attestationBytes);

        byte[] signature = ecdsaSign.sign();
        return Base64.getEncoder().encodeToString(signature);
    }
    
    /**
     * 辅助方法,用于计算文件的SHA-256哈希.
     * @param filePath 文件路径
     * @return 文件的SHA-256哈希值(小写十六进制字符串)
     * @throws IOException 如果读取文件失败
     * @throws NoSuchAlgorithmException 如果SHA-256算法不可用
     */
    public static String calculateSha256(Path filePath) throws IOException, NoSuchAlgorithmException {
        MessageDigest digest = MessageDigest.getInstance("SHA-256");
        try (InputStream fis = Files.newInputStream(filePath)) {
            byte[] byteArray = new byte[8192];
            int bytesCount;
            while ((bytesCount = fis.read(byteArray)) != -1) {
                digest.update(byteArray, 0, bytesCount);
            }
        }
        byte[] bytes = digest.digest();
        StringBuilder sb = new StringBuilder();
        for (byte aByte : bytes) {
            sb.append(Integer.toString((aByte & 0xff) + 0x100, 16).substring(1));
        }
        return sb.toString();
    }

    /**
     * 用于本地测试的辅助方法,生成一个EC密钥对.
     */
    public static KeyPair generateKeyPair() throws GeneralSecurityException {
        KeyPairGenerator keyGen = KeyPairGenerator.getInstance("EC", "BC");
        keyGen.initialize(256, new SecureRandom());
        return keyGen.generateKeyPair();
    }
}

这里的坑在于,对attestation对象签名时,必须确保其序列化后的JSON字节流是稳定且确定的。任何微小的格式变化(如字段顺序、空格)都会导致签名验证失败。这就是为什么在生产代码中,我们通常会对JSON进行规范化(Canonicalization)处理,但对于这个场景,只要保证序列化和验证时使用相同的逻辑即可。

3. 实现核心验证服务 ArtifactVerifier

这是部署流程中的“验票”环节,是安全保障的最后一道防线。

// File: io/devsecops/verifier/ArtifactVerifier.java

package io.devsecops.verifier;

import com.fasterxml.jackson.databind.ObjectMapper;
import io.devsecops.attestation.ArtifactAttestation;
import io.devsecops.signer.ArtifactSigner;

import java.io.IOException;
import java.nio.file.Path;
import java.security.*;
import java.util.Base64;
import java.util.Objects;
import org.bouncycastle.jce.provider.BouncyCastleProvider;


/**
 * 负责验证构件的签名和证明.
 */
public class ArtifactVerifier {

    private static final String SIGNATURE_ALGORITHM = "SHA256withECDSA";
    private static final ObjectMapper MAPPER = new ObjectMapper();

    static {
        Security.addProvider(new BouncyCastleProvider());
    }

    private final PublicKey publicKey;

    public ArtifactVerifier(PublicKey publicKey) {
        this.publicKey = Objects.requireNonNull(publicKey, "Public key cannot be null");
    }

    /**
     * 核心验证方法.
     * @param attestationJson 证明文件的JSON内容
     * @param signatureBase64 Base64编码的签名
     * @param artifactPath 待验证的构件文件路径
     * @param sbomPath 待验证的SBOM文件路径
     * @return 如果验证通过,返回true
     * @throws GeneralSecurityException 密码学相关错误
     * @throws IOException 文件IO或JSON解析错误
     */
    public boolean verify(String attestationJson, String signatureBase64, Path artifactPath, Path sbomPath)
            throws GeneralSecurityException, IOException {
        
        // 1. 验证签名本身是否有效
        byte[] signatureBytes = Base64.getDecoder().decode(signatureBase64);
        Signature ecdsaVerify = Signature.getInstance(SIGNATURE_ALGORITHM, "BC");
        ecdsaVerify.initVerify(publicKey);
        ecdsaVerify.update(attestationJson.getBytes(StandardCharsets.UTF_8));
        
        if (!ecdsaVerify.verify(signatureBytes)) {
            // 日志记录:签名校验失败
            System.err.println("Verification failed: Signature is invalid for the given attestation.");
            return false;
        }

        // 2. 签名有效,现在验证证明内容是否与实际文件匹配
        ArtifactAttestation attestation = MAPPER.readValue(attestationJson, ArtifactAttestation.class);
        
        String expectedArtifactHash = attestation.getArtifactIdentity().getArtifactSha256();
        String actualArtifactHash = ArtifactSigner.calculateSha256(artifactPath);

        if (!expectedArtifactHash.equals(actualArtifactHash)) {
            // 日志记录:构件哈希不匹配
            System.err.printf("Verification failed: Artifact hash mismatch. Expected: %s, Actual: %s%n",
                    expectedArtifactHash, actualArtifactHash);
            return false;
        }

        String expectedSbomHash = attestation.getArtifactIdentity().getSbomSha256();
        String actualSbomHash = ArtifactSigner.calculateSha256(sbomPath);

        if (!expectedSbomHash.equals(actualSbomHash)) {
            // 日志记录:SBOM哈希不匹配
            System.err.printf("Verification failed: SBOM hash mismatch. Expected: %s, Actual: %s%n",
                    expectedSbomHash, actualSbomHash);
            return false;
        }
        
        // 3. (可选) 可以在此添加策略检查,例如检查构建时间是否在合理范围内,构建者ID是否可信等
        
        return true;
    }
}

验证逻辑必须严密,分为两步:首先,确认签名对于证明文件是有效的,这保证了证明文件本身未被篡改;其次,确认证明文件中的哈希值与实际文件的哈希值一致,这保证了构件和SBOM也未被篡改。

4. 集成到CI/CD流水线

这是将技术落地到敏捷流程的关键。我们将上述Java代码打包成一个可执行的JAR (devsecops-tool.jar)。下面是一个简化的GitHub Actions工作流示例:

# .github/workflows/build-and-sign.yml
name: Build, Sign, and Verify

on:
  push:
    branches: [ "main" ]

jobs:
  build-and-sign:
    runs-on: ubuntu-latest
    # 假设配置了云服务商的OIDC,可以获取临时凭证来调用KMS
    permissions:
      id-token: write
      contents: read
    
    steps:
    - name: Checkout code
      uses: actions/checkout@v3

    - name: Set up JDK 17
      uses: actions/setup-java@v3
      with:
        java-version: '17'
        distribution: 'temurin'

    - name: Build with Maven and Generate SBOM
      run: |
        mvn package
        # 使用CycloneDX Maven插件生成SBOM
        mvn org.cyclonedx:cyclonedx-maven-plugin:makeAggregateBom

    - name: Download DevSecOps Tool
      run: |
        # 从一个安全的位置下载我们的签名工具
        wget https://internal-repo/devsecops-tool.jar

    - name: Sign the Artifact
      id: sign
      run: |
        # 实际场景中,这里的密钥管理会通过IAM角色和KMS API完成
        # 为演示,我们生成临时密钥并导出公钥
        java -jar devsecops-tool.jar generate-key-pair --private-key-out private.key --public-key-out public.pem
        
        # 调用签名命令
        java -jar devsecops-tool.jar sign \
          --artifact-path target/my-app-1.0.jar \
          --sbom-path target/bom.xml \
          --private-key private.key \
          --attestation-out target/attestation.json \
          --signature-out target/signature.sig \
          --builder-id "${{ github.run_id }}" \
          --git-commit "${{ github.sha }}"
      
    - name: Upload Artifacts
      uses: actions/upload-artifact@v3
      with:
        name: signed-release
        path: |
          target/my-app-1.0.jar
          target/bom.xml
          target/attestation.json
          target/signature.sig
          public.pem # 上传公钥用于后续验证

  deploy:
    needs: build-and-sign
    runs-on: ubuntu-latest
    
    steps:
    - name: Download Signed Artifacts
      uses: actions/download-artifact@v2
      with:
        name: signed-release

    - name: Download DevSecOps Tool
      run: wget https://internal-repo/devsecops-tool.jar
      
    - name: Pre-Deployment Verification
      run: |
        java -jar devsecops-tool.jar verify \
          --artifact-path my-app-1.0.jar \
          --sbom-path bom.xml \
          --attestation-path attestation.json \
          --signature-path signature.sig \
          --public-key public.pem

    - name: Deploy to Staging
      run: |
        echo "Verification successful! Deploying..."
        # ... 部署脚本 ...

这个流水线清晰地展示了“构建->签名->发布->验证->部署”的流程,实现了安全检查的自动化左移,完全融入了敏捷开发的高速迭代中。

最终成果:一条自动化的信任链

经过一个迭代的开发和测试,我们成功地将这套机制集成到了核心产品的CI/CD流水线中。现在,每次构建都会在Artifactory中产生五个文件:my-app.jar, bom.xml, attestation.json, signature.sig, 以及一个指向本次构建所用公钥的引用。

graph TD
    subgraph "CI/CD Pipeline"
        A[Git Push on main] --> B{Maven Build & Test};
        B --> C[Generate JAR & CycloneDX SBOM];
        C --> D[Execute devsecops-tool.jar sign];
        D --> E{Generate attestation.json & signature.sig};
        E --> F[Publish All Artifacts to Artifactory];
    end

    subgraph "Deployment Workflow (e.g., ArgoCD Pre-Sync Hook)"
        G[Deployment Trigger] --> H{Fetch Artifacts from Artifactory};
        H --> I[Execute devsecops-tool.jar verify];
        I --> J{Verification Check};
        J -- Success --> K[Deploy Container to Kubernetes];
        J -- Failure --> L[Abort Deployment & Alert Security Team];
    end

    F --> H

这个流程的建立,让安全团队和开发团队达成了共识。开发人员无需关心签名的复杂细节,只需确保流水线正常运行。安全和运维团队则获得了一个强有力的控制门禁,任何未经流水线正式签名的构件都无法进入生产环境。

遗留问题与未来迭代

尽管目前的方案解决了我们最紧迫的问题,但在务实的工程师眼中,它远非完美。

首先,公钥的分发和管理依然是一个薄弱环节。目前我们将公钥与构件一起存储,但这依赖于对Artifactory本身的信任。更健壮的方案是建立一个独立的、高可信的公钥基础设施(PKI),或者利用服务网格(如Istio)配合SPIFFE/SPIRE来做身份分发和验证。

其次,我们的ArtifactAttestation是自定义格式。为了更好地与外部工具生态兼容,我们应该迁移到更标准的格式,比如in-toto Attestation Framework。这能让我们利用更多开源工具进行策略执行,例如Open Policy Agent (OPA),实现更复杂的验证规则(如“禁止部署包含高危漏洞的构件”)。

最后,当前的机制保证了“构建->部署”环节的安全,但并未完全覆盖“源码->构建”环节。一个高级的攻击者可能会在构建服务器本身被攻陷的情况下,污染构建过程。解决这个问题,需要引入可复现构建(Reproducible Builds)和更严格的构建环境隔离,这是我们下个季度的探索方向。


  目录