构建支持NumPy与WebAuthn的安卓应用的可验证CI/CD供应链


一个棘手的需求摆在了我们面前:开发一款安卓应用,需要在设备本地执行复杂的数值计算,以保障用户生物特征数据的绝对隐私,不离开设备。同时,用户认证必须采用无密码的 WebAuthn 方案。这意味着,我们需要将 Python 的科学计算库 NumPy 嵌入到安卓原生环境中,并为这个混合技术栈构建一个从代码提交到制品交付,全程可验证、可审计的安全供应链。

这不再是一个常规的移动 CI/CD 任务。它横跨了原生开发(Kotlin)、Python 生态、后端服务(WebAuthn 认证器)以及新兴的软件供应链安全领域。我们的初步构想是,必须将软件物料清单(SBOM)生成和制品签名(Signing)作为流水线的一等公民,而非事后补救。

初步构想与技术选型

最初的技术选型围绕着如何在安卓上运行 Python/NumPy。经过调研,Chaquopy 插件脱颖而出。它能无缝地将 Python 代码和库集成到 Gradle 构建流程中,并提供 Kotlin/Java 与 Python 之间的直接调用桥梁。

后端 WebAuthn 服务,我们选择了 FastAPI,因为它轻量、高性能,且有成熟的 webauthn 库支持。

CI/CD 平台则定为 GitHub Actions,利用其丰富的生态和对容器化构建的良好支持。

整个流程的核心挑战在于将这些孤立的部分串联起来,并注入安全验证。

graph TD
    subgraph "GitHub Repository"
        A[Push to main branch]
    end

    subgraph "GitHub Actions Workflow"
        A --> B{CI Trigger};
        B --> C[Stage 1: Backend CI];
        B --> D[Stage 2: Android CI];

        subgraph "Backend Pipeline"
            C --> C1[Build & Test FastAPI App];
            C1 --> C2[Build Docker Image];
            C2 --> C3[Generate Backend SBOM];
            C3 --> C4[Sign Docker Image w/ Cosign];
            C4 --> C5[Push to Registry];
        end

        subgraph "Android Pipeline"
            D --> D1[Build Release AAB];
            D1 --> D2[Generate Android SBOM];
            D2 --> D3[Sign AAB w/ Cosign];
            D3 --> D4[Upload to Artifacts];
        end

        C5 --> E{Deployment};
        D4 --> E;
    end

步骤化实现:从本地到云端

1. 安卓项目集成 NumPy

一切从 build.gradle.kts 文件开始。集成 Chaquopy 需要精确的配置。

// app/build.gradle.kts

plugins {
    id("com.android.application")
    id("org.jetbrains.kotlin.android")
    id("com.chaquo.python") // 关键的 Chaquopy 插件
}

android {
    // ... standard android config ...

    defaultConfig {
        // ...
        ndk {
            abiFilters.addAll(listOf("arm64-v8a", "x86_64"))
        }

        python {
            // 指定 Python 解释器版本
            version = "3.11"
            // 通过 pip 安装 NumPy
            pip {
                install("numpy==1.26.1")
                install("scikit-learn==1.3.2") // 顺便加入 scikit-learn 以展示更复杂的计算
            }
            // 将 Python 源码目录告知 Chaquopy
            sourceSets {
                getByName("main") {
                    srcDir("src/main/python")
                }
            }
        }
    }
    // ...
}

这里的坑在于 abiFilters。NumPy 包含大量 C 扩展,必须为目标设备架构提供对应的二进制文件。省略或配置错误会导致运行时 UnsatisfiedLinkError

接着,在 src/main/python/ 目录下创建我们的计算脚本。这不是一个简单的 “hello world”,而是一个模拟处理传感器数据的函数。

# src/main/python/calculator.py

import numpy as np
from sklearn.preprocessing import normalize

def process_sensor_data(raw_data: list[float]) -> dict:
    """
    对传入的原始传感器浮点数列表进行统计分析。
    这是一个计算密集型任务,适合在本地执行以保护隐私。
    
    Args:
        raw_data: 原始数据列表。
        
    Returns:
        一个包含计算结果的字典。
    """
    if not raw_data or len(raw_data) < 2:
        return {
            "error": "Input data must contain at least two elements."
        }

    try:
        # 将输入数据转换为 NumPy 数组,这是所有操作的基础
        data_array = np.array(raw_data, dtype=np.float64)

        # 核心计算
        mean = np.mean(data_array)
        std_dev = np.std(data_array)
        variance = np.var(data_array)
        max_val = np.max(data_array)
        min_val = np.min(data_array)

        # 使用 scikit-learn 进行 L2 归一化,模拟更复杂的 ML 预处理
        # 需要将一维数组重塑为二维
        normalized_data = normalize(data_array.reshape(1, -1), norm='l2')

        return {
            "mean": mean,
            "std_dev": std_dev,
            "variance": variance,
            "max": max_val,
            "min": min_val,
            "normalized_sample": normalized_data.flatten().tolist()[:5] # 返回前5个归一化样本
        }
    except Exception as e:
        # 在 Python 端捕获异常并返回,便于 Kotlin 端处理
        return {"error": str(e)}

最后,在 Kotlin 中调用它。

// src/main/java/com/example/secureapp/computation/LocalProcessor.kt

package com.example.secureapp.computation

import android.content.Context
import com.chaquo.python.PyException
import com.chaquo.python.Python
import com.chaquo.python.android.AndroidPlatform
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken

// 定义数据类以匹配 Python 字典的返回结构
data class ProcessingResult(
    val mean: Double?,
    val std_dev: Double?,
    val variance: Double?,
    val max: Double?,
    val min: Double?,
    val normalized_sample: List<Double>?,
    val error: String?
)

class LocalProcessor(context: Context) {

    private val pyInstance: Python

    init {
        // 在应用启动时(或首次使用时)初始化 Python 环境
        if (!Python.isStarted()) {
            Python.start(AndroidPlatform(context))
        }
        pyInstance = Python.getInstance()
    }

    fun analyze(data: List<Double>): ProcessingResult {
        return try {
            val calculatorModule = pyInstance.getModule("calculator")
            
            // 调用 Python 函数,Chaquopy 会自动处理 List<Double> 到 Python list 的转换
            val pyResult = calculatorModule.callAttr("process_sensor_data", data)
            
            // 将 Python dict 转换为 JSON 字符串,再用 Gson 解析为 Kotlin 数据类
            // 这是一个健壮的、类型安全的数据交换方式
            val resultJson = pyResult.toString()
            val type = object : TypeToken<ProcessingResult>() {}.type
            Gson().fromJson(resultJson, type)

        } catch (e: PyException) {
            // 捕获 Python 侧的异常
            ProcessingResult(null, null, null, null, null, null, "Python execution error: ${e.message}")
        }
    }
}

这种通过 JSON 序列化进行数据交换的方式,虽然比直接映射稍慢,但在处理复杂数据结构时,可维护性和健壮性要高得多。

2. 实现 WebAuthn 后端服务

后端的 FastAPI 应用负责处理 WebAuthn 注册和登录的挑战-响应流程。

# backend/main.py

import uvicorn
from fastapi import FastAPI, HTTPException, Body
from pydantic import BaseModel
from webauthn import (
    generate_registration_options,
    verify_registration_response,
    generate_authentication_options,
    verify_authentication_response,
)
from webauthn.helpers.structs import (
    RegistrationCredential,
    AuthenticationCredential,
    PublicKeyCredentialCreationOptions,
    PublicKeyCredentialRequestOptions,
)

# 在真实项目中,这里应该是持久化存储,例如数据库
# 为了示例简化,我们使用内存中的字典
user_credentials = {}
users_db = {"testuser": {"id": b"testuser-id-12345", "name": "Test User"}}

RP_ID = "localhost"  # 必须与前端请求的域名匹配
RP_NAME = "Secure NumPy App"
ORIGIN = "http://localhost:3000" # 假设有 web 端调试

app = FastAPI()

class User(BaseModel):
    username: str

@app.post("/generate-registration-options", response_model=PublicKeyCredentialCreationOptions)
def get_reg_options(user: User):
    if user.username not in users_db:
        raise HTTPException(status_code=404, detail="User not found")

    user_info = users_db[user.username]
    
    options = generate_registration_options(
        rp_id=RP_ID,
        rp_name=RP_NAME,
        user_id=user_info["id"],
        user_name=user_info["name"],
        # 避免用户注册已经存在的凭证
        exclude_credentials=[
            {"id": cred.id, "type": "public-key"} for cred in user_credentials.get(user.username, [])
        ],
    )
    return options

@app.post("/verify-registration")
def verify_reg(credential: RegistrationCredential, username: str = Body(...)):
    if username not in users_db:
        raise HTTPException(status_code=404, detail="User not found")
        
    try:
        verification = verify_registration_response(
            credential=credential,
            expected_origin=ORIGIN,
            expected_rp_id=RP_ID,
        )
        # 存储新凭证
        if username not in user_credentials:
            user_credentials[username] = []
        user_credentials[username].append(verification)
        return {"verified": True}
    except Exception as e:
        raise HTTPException(status_code=400, detail=f"Registration failed: {e}")

@app.post("/generate-authentication-options", response_model=PublicKeyCredentialRequestOptions)
def get_auth_options(user: User):
    if user.username not in user_credentials:
        raise HTTPException(status_code=404, detail="User has no credentials registered")

    options = generate_authentication_options(
        rp_id=RP_ID,
        allow_credentials=[
            {"id": cred.credential_id, "type": "public-key"}
            for cred in user_credentials[user.username]
        ],
    )
    return options

@app.post("/verify-authentication")
def verify_auth(credential: AuthenticationCredential, username: str = Body(...)):
    if username not in user_credentials:
        raise HTTPException(status_code=404, detail="User has no credentials registered")
        
    user_creds = user_credentials[username]
    
    for cred in user_creds:
        try:
            verification = verify_authentication_response(
                credential=credential,
                expected_origin=ORIGIN,
                expected_rp_id=RP_ID,
                credential_public_key=cred.credential_public_key,
                credential_current_sign_count=cred.sign_count,
            )
            # 更新签名计数器,防止重放攻击
            cred.sign_count = verification.new_sign_count
            return {"verified": True}
        except Exception:
            continue
            
    raise HTTPException(status_code=400, detail="Authentication failed")

if __name__ == "__main__":
    uvicorn.run(app, host="0.0.0.0", port=8000)

这个后端服务虽然简单,但完整覆盖了 WebAuthn 的核心流程,并考虑了多凭证和重放攻击等基本安全问题。在生产环境中,user_credentialsusers_db 必须换成真正的数据库,并且 RP_IDORIGIN 需要从配置中读取。

3. 编排可验证的 CI/CD 流水线

这是整个方案的核心,我们将所有组件的构建、测试、验证和签名流程固化在 GitHub Actions 中。

# .github/workflows/main.yml

name: Verifiable Mobile CI/CD

on:
  push:
    branches: [ "main" ]
  pull_request:
    branches: [ "main" ]

env:
  # 使用 GCR 作为示例镜像仓库
  IMAGE_REGISTRY: gcr.io
  IMAGE_NAME: secure-numpy-app-backend
  PROJECT_ID: your-gcp-project-id

jobs:
  backend-ci-cd:
    name: Backend CI & Signing
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
      id-token: write # 需要为 cosign keyless signing 获取 OIDC token

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      # 省略了 Python test 步骤,真实项目中必须有
      
      - name: Authenticate to Google Cloud
        uses: 'google-github-actions/auth@v2'
        with:
          workload_identity_provider: 'projects/${{ secrets.GCP_PROJECT_NUMBER }}/locations/global/workloadIdentityPools/${{ secrets.GCP_WIF_POOL_ID }}/providers/${{ secrets.GCP_WIF_PROVIDER_ID }}'
          service_account: '${{ secrets.GCP_SA_EMAIL }}'

      - name: Build and push Docker image
        id: build-and-push
        uses: docker/build-push-action@v5
        with:
          context: ./backend
          push: true
          tags: ${{ env.IMAGE_REGISTRY }}/${{ env.PROJECT_ID }}/${{ env.IMAGE_NAME }}:latest

      - name: Generate SBOM for Docker Image
        uses: anchore/syft-action@v0
        with:
          image: ${{ steps.build-and-push.outputs.digest }}
          format: spdx-json
          output-file: "backend.spdx.json"

      - name: Install Cosign
        uses: sigstore/cosign-installer@v3

      - name: Sign the container image with Cosign
        run: |
          cosign sign --yes \
            "${{ env.IMAGE_REGISTRY }}/${{ env.PROJECT_ID }}/${{ env.IMAGE_NAME }}@${{ steps.build-and-push.outputs.digest }}"
        
      - name: Upload SBOM artifact
        uses: actions/upload-artifact@v4
        with:
          name: backend-sbom
          path: backend.spdx.json

  android-ci-cd:
    name: Android Build & Signing
    runs-on: ubuntu-latest
    needs: backend-ci-cd # 可以并行,但此处设为依赖以展示顺序
    permissions:
      contents: read
      id-token: write
      
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

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

      - name: Gradle Caching
        uses: actions/cache@v4
        with:
          path: |
            ~/.gradle/caches
            ~/.gradle/wrapper
          key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
          restore-keys: |
            ${{ runner.os }}-gradle-

      - name: Grant execute permission for gradlew
        run: chmod +x ./gradlew

      - name: Build Android App Bundle (AAB)
        run: ./gradlew bundleRelease
        env:
          SIGNING_KEY_ALIAS: ${{ secrets.SIGNING_KEY_ALIAS }}
          SIGNING_KEY_PASSWORD: ${{ secrets.SIGNING_KEY_PASSWORD }}
          SIGNING_STORE_PASSWORD: ${{ secrets.SIGNING_STORE_PASSWORD }}
          SIGNING_KEY_BASE64: ${{ secrets.SIGNING_KEY_BASE64 }}
      
      # 解码签名密钥
      - name: Decode Keystore
        run: |
          echo "${{ secrets.SIGNING_KEY_BASE64 }}" | base64 --decode > ${{ github.workspace }}/app/keystore.jks

      - name: Sign AAB
        run: |
          jarsigner -keystore ${{ github.workspace }}/app/keystore.jks \
            -storepass ${{ secrets.SIGNING_STORE_PASSWORD }} \
            -keypass ${{ secrets.SIGNING_KEY_PASSWORD }} \
            app/build/outputs/bundle/release/app-release.aab \
            ${{ secrets.SIGNING_KEY_ALIAS }}

      - name: Generate SBOM for Android Dependencies
        run: |
          # Syft 目前对 Gradle 的直接支持有限,一个变通方法是扫描 Gradle 缓存或依赖报告
          # 这里我们仅作演示,扫描整个项目目录以捕获依赖
          curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin
          syft . --file app/build/reports/dependencies.txt -o spdx-json > android.spdx.json

      - name: Install Cosign
        uses: sigstore/cosign-installer@v3

      - name: Sign the AAB with Cosign as a blob
        run: |
          cosign sign-blob --yes \
            --output-signature app-release.aab.sig \
            --output-certificate app-release.aab.pem \
            app/build/outputs/bundle/release/app-release.aab

      - name: Upload AAB and Signatures
        uses: actions/upload-artifact@v4
        with:
          name: signed-android-artifacts
          path: |
            app/build/outputs/bundle/release/app-release.aab
            app-release.aab.sig
            app-release.aab.pem
            android.spdx.json

这个流水线有几个关键点:

  1. 权限 (permissions): 为 Cosign 的无密钥签名(keyless signing)模式授予 id-token: write 权限,它利用 GitHub Actions 的 OIDC 提供商来换取 Sigstore Fulcio CA 颁发的短期证书。
  2. 制品签名: 我们执行了两次签名。一次是标准的 Android 应用签名(jarsigner),用于让 Android 系统和应用商店认可。另一次是使用 cosign sign-blob 对最终的 AAB 文件本身进行签名,这次签名是为了软件供应链的可追溯性,证明这个二进制文件确实是由这个 CI/CD 作业生成的。
  3. SBOM 生成: 使用 Anchore Syft 工具。对于 Docker 镜像,Syft 可以直接分析其文件系统层。对于 Android,由于 Gradle 复杂的依赖管理,直接扫描可能会不精确。在生产级实现中,通常会利用 Gradle 插件(如 com.github.spdx.gradle-plugin)来生成更精确的 SBOM。
  4. 密钥管理: Android 签名密钥通过 GitHub Secrets 以 Base64 编码的形式存储,在运行时解码使用。这是一个常见的实践,但更安全的方式是使用云服务商的密钥管理服务(KMS)。

遗留的挑战与未来迭代

这个方案虽然解决了核心问题,但在生产环境中仍有局限性和需要完善之处。

首先,性能与包体积是在安卓设备上嵌入 Python 解释器和 NumPy 库的直接代价。应用的初始加载时间会变长,AAB 文件体积会显著增加。后续优化可以探索使用 ProGuard/R8 对 Python 字节码进行收缩,或者裁剪 NumPy 库,只保留需要的功能。

其次,SBOM 的消费。我们生成了 SBOM,但还没有在流水线的任何地方使用它来进行验证。一个完整的 DevSecOps 闭环应该包含一个策略执行步骤,例如使用 OPA (Open Policy Agent) Gatekeeper 或 Grype(另一个 Anchore 工具)来扫描 SBOM,检查是否存在已知漏洞或不合规的许可证,如果发现问题则自动中断流水线。

最后,端到端的验证链。我们用 Cosign 签名了制品,但验证过程是离线的。理想情况下,部署平台(例如 Kubernetes 集群或应用分发系统)应该集成对 Sigstore 签名的验证。在接收到一个新的 Docker 镜像或 AAB 文件时,它会先去 Rekor 透明日志中查找对应的签名记录,验证签名有效且来源可信后,才允许部署或分发。这构成了从代码到运行时的完整信任链。


  目录