一个棘手的需求摆在了我们面前:开发一款安卓应用,需要在设备本地执行复杂的数值计算,以保障用户生物特征数据的绝对隐私,不离开设备。同时,用户认证必须采用无密码的 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_credentials 和 users_db 必须换成真正的数据库,并且 RP_ID 和 ORIGIN 需要从配置中读取。
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
这个流水线有几个关键点:
- 权限 (
permissions): 为 Cosign 的无密钥签名(keyless signing)模式授予id-token: write权限,它利用 GitHub Actions 的 OIDC 提供商来换取 Sigstore Fulcio CA 颁发的短期证书。 - 制品签名: 我们执行了两次签名。一次是标准的 Android 应用签名(
jarsigner),用于让 Android 系统和应用商店认可。另一次是使用cosign sign-blob对最终的 AAB 文件本身进行签名,这次签名是为了软件供应链的可追溯性,证明这个二进制文件确实是由这个 CI/CD 作业生成的。 - SBOM 生成: 使用 Anchore Syft 工具。对于 Docker 镜像,Syft 可以直接分析其文件系统层。对于 Android,由于 Gradle 复杂的依赖管理,直接扫描可能会不精确。在生产级实现中,通常会利用 Gradle 插件(如
com.github.spdx.gradle-plugin)来生成更精确的 SBOM。 - 密钥管理: 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 透明日志中查找对应的签名记录,验证签名有效且来源可信后,才允许部署或分发。这构成了从代码到运行时的完整信任链。