diff --git a/README.en.md b/README.en.md index e77dd00..822097a 100644 --- a/README.en.md +++ b/README.en.md @@ -138,7 +138,7 @@ Shellsmith offers two key-delivery modes: | Local offline key | Derived on the build machine and wrapped into the APK | No network or key service | Fully offline delivery, no backend | | Online key (BYOS) | Your self-hosted HTTPS service | Network authorization on every launch | Device approval, Play Integrity, device/artifact revocation | -Local offline keys are the default. To move the IKM out of the APK, select a BYOS service under **Key Delivery**. Choose the mode per protection task; online mode never falls back to offline keys when authorization fails. The rest of this section covers the online key mode. +Local offline keys are the initial default. To move the IKM out of the APK, select a BYOS service under **Key Delivery**. The desktop app restores the last selected service or offline mode on restart. Service details and the build API key are encrypted in local storage. Leave the key field blank when editing to keep the saved credential. BYOS (Bring Your Own Server) moves the artifact IKM out of the APK. It is intended for teams that manage their own keys, device authorization, and revocation policy. @@ -247,7 +247,9 @@ The iOS balanced and strict profiles integrate freeRASP directly into the target | BYOS local validation and real-device troubleshooting | [Local validation](docs/process/byos-local-validation.md) | | Future work | [Roadmap](docs/process/roadmap.md) | -The CLI and MCP server are source-built; local `make release` / `scripts/release-cli.sh` CLI packages include both `shield` and `shellsmith-mcp`. Build `make build-stub` first, then `make build-cli` or `make build-gui`. Refer to the build guide for dependencies and platform-specific steps. +`apps/shield-gui` is the Tauri desktop frontend for hardening, signing, certificate management, and settings. `tools/stats-worker` provides JSON endpoints for maintenance statistics, error reports, and feedback. See the [desktop architecture](docs/design/gui.md) and [maintenance statistics guide](docs/ops/project-statistics.md). + +Use the local CLI or stdio MCP server to automate hardening, signing, preflight checks, and report reading. See the [MCP guide](docs/mcp.md) for installation and client setup. Local `make release` / `scripts/release-cli.sh` CLI packages include both `shield` and `shellsmith-mcp`. Run `make build-stub` first, then `make build-cli` or `make build-gui`.
CI/CD and Cross-Platform Releases diff --git a/README.md b/README.md index 1d7af0b..003934c 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ Android 侧提供 APK/AAB 的 DEX 加密、证书绑定、PVM2 方法虚拟化 - **加固前检查与失败诊断**:检查签名、已有加固、系统要求、ABI 等兼容信号;失败时给出脱敏诊断摘要,可预览后确认发送。 - **少重复配置**:记住常用加固选项和证书选择,支持自定义输出目录与文件名,任务开始后固定本次配置。 - **安装兼容**:处理 APK ZIP 对齐及常见 Native 库问题;标准模式与 Android 4.4 工控模式分开选择。 +- **CLI 与 MCP**:在本机脚本或支持 stdio MCP 的客户端中执行加固、签名、预检和报告读取,可查询和取消长任务。接入方式见 [MCP 指南](docs/mcp.md)。 两条链路都用于提高静态分析、篡改和非法重签的成本,不承诺无法脱壳或逆向。 @@ -45,6 +46,8 @@ Android 侧提供 APK/AAB 的 DEX 加密、证书绑定、PVM2 方法虚拟化 | macOS | `Shellsmith_x.y.z_macos_universal.dmg` | | Linux | `Shellsmith_x.y.z_linux_amd64.AppImage` 或 `.deb` | +需要命令行或 MCP 时,可下载发布页中可用的 `Shellsmith-cli--` 包,或按 [MCP 指南](docs/mcp.md) 从源码构建。 + ### 环境要求 - 完整 JDK 8 或更高版本,确保 `java`、`keytool` 可用;使用 PVM2 需要 Java 17+。 @@ -157,10 +160,12 @@ BYOS(Bring Your Own Server)把产物 IKM 从 APK 中移出,适合需要自 1. 按协议部署自建 HTTPS 服务端,创建应用策略和构建 API key。源码构建需分别生成设备授权和 Play 资源:`make build-byos`、`make build-byos-play`;正式发布包已包含对应资源。 2. 在 Shellsmith 的“密钥交付 → 新增服务”中填写 HTTPS 基础地址、构建 API key、授权方式和最终证书 SHA-256。 -3. 测试连接,加固并签名 APK。每个产物注册后获得新的 `token_id`。 +3. 测试连接,点击“保存并使用”,再加固并签名 APK。每个产物注册后获得新的 `token_id`。 4. 设备模式首次启动会被拒绝,管理员批准设备后重新启动;Play 模式使用 Play 测试轨道 APK 验证。 5. 撤销设备或产物后,后续取钥失败;已经进入进程的 IKM 和明文代码无法远程收回。 +桌面端会在重启后恢复上次选择的服务或本地离线模式。服务资料和构建 API key 在本地加密保存,编辑时可留空沿用已保存的 key。被加固的 APK 每次启动仍按服务端策略取钥。 + ### 安全边界 - BYOS 让 APK 本身不含 IKM,降低仅凭 APK 离线恢复业务密钥的风险;它不等于 DRM,不能阻止已授权进程的内存提取、Hook 或二次打包分析。 @@ -169,7 +174,7 @@ BYOS(Bring Your Own Server)把产物 IKM 从 APK 中移出,适合需要自 - 构建 API key 只用于加固注册,不进入 APK,也不具备运行时授权能力。 - AAB、iOS 和 Android 4.4 兼容模式不支持 BYOS。 -Shellsmith 不托管 BYOS 服务端。仓库提供一份可独立部署的 Go/PostgreSQL 参考实现,包含设备批准、Play Integrity、防重放、审计、主密钥轮换和 `migrate-backend`,支持本机 `local_aead`、AWS KMS、GCP KMS(含 Cloud HSM)和 PKCS#11 HSM。 +BYOS 由使用者自行部署。仓库记录了独立的 Go/PostgreSQL 服务端参考实现,包含设备批准、Play Integrity、防重放、审计、主密钥轮换和 `migrate-backend`,支持本机 `local_aead`、AWS KMS、GCP KMS(含 Cloud HSM)和 PKCS#11 HSM。管理操作通过服务端 CLI 完成。 - [BYOS HTTPS 密钥服务接入协议](docs/design/byos-key-server.md) - [服务端参考实现与部署](docs/design/byos-key-server-reference.md) @@ -247,7 +252,9 @@ iOS balanced/strict 会让目标应用直接集成 freeRASP;其 `watcherMail` | BYOS 本地验证与真实设备排障 | [本地验证记录](docs/process/byos-local-validation.md) | | 后续规划 | [路线图](docs/process/roadmap.md) | -CLI 与 MCP Server 可从源码构建;`make release` / `scripts/release-cli.sh` 生成的本地 CLI 包同时包含 `shield` 和 `shellsmith-mcp`。构建顺序为先 `make build-stub`,再 `make build-cli` 或 `make build-gui`;完整依赖与平台步骤以构建指南为准。 +`apps/shield-gui` 是由 Tauri 承载的桌面前端,提供加固、签名、证书和设置界面。`tools/stats-worker` 提供维护统计、错误报告和用户反馈的 JSON 接口。参见[桌面架构](docs/design/gui.md)和[项目维护统计](docs/ops/project-statistics.md)。 + +`make release` / `scripts/release-cli.sh` 生成的本地 CLI 包包含 `shield` 和 `shellsmith-mcp`。构建顺序为先 `make build-stub`,再 `make build-cli` 或 `make build-gui`。完整依赖与平台步骤见构建指南。
CI/CD 与跨平台发布 diff --git a/apps/shield-gui/src-tauri/src/byos.rs b/apps/shield-gui/src-tauri/src/byos.rs index 270cb6a..e02d9ba 100644 --- a/apps/shield-gui/src-tauri/src/byos.rs +++ b/apps/shield-gui/src-tauri/src/byos.rs @@ -1,9 +1,10 @@ use crate::cert_store::CertificateStoreState; -use rusqlite::{params, OptionalExtension}; +use rusqlite::{params, Connection, OptionalExtension}; use serde::{Deserialize, Serialize}; use shield_core::local_secret::{decrypt_secret, encrypt_secret, load_storage_key}; const PREFIX: &str = "byos_profile:"; +const SELECTED: &str = "selected_byos_profile_id"; #[derive(Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -32,6 +33,13 @@ pub(crate) struct ProfileInput { pub api_key: Option, } +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct Profiles { + profiles: Vec, + selected_id: Option, +} + fn record_key(id: &str) -> Result { uuid::Uuid::parse_str(id).map_err(|_| "BYOS 配置标识无效")?; Ok(format!("{PREFIX}{id}")) @@ -122,8 +130,11 @@ fn resolve_input( fn save(store: &CertificateStoreState, input: ProfileInput) -> Result { let saved = resolve_input(store, input)?; validate(&saved)?; - let conn = store.open()?; - let count: i64 = conn + let mut conn = store.open()?; + let tx = conn + .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) + .map_err(|_| "保存 BYOS 配置失败")?; + let count: i64 = tx .query_row( "SELECT count(*) FROM app_state WHERE key LIKE 'byos_profile:%'", [], @@ -132,31 +143,74 @@ fn save(store: &CertificateStoreState, input: ProfileInput) -> Result, -) -> Result, String> { +fn set_selected(conn: &Connection, id: Option<&str>) -> Result<(), String> { + if let Some(id) = id { + let updated = conn.execute( + "INSERT INTO app_state(key,value) SELECT ?1,?2 WHERE EXISTS(SELECT 1 FROM app_state WHERE key=?3) ON CONFLICT(key) DO UPDATE SET value=excluded.value", + params![SELECTED, id, record_key(id)?], + ).map_err(|_| "保存 BYOS 选择失败")?; + if updated == 0 { + return Err("未找到 BYOS 配置,请重新选择".into()); + } + } else { + conn.execute("DELETE FROM app_state WHERE key=?1", [SELECTED]) + .map_err(|_| "保存 BYOS 选择失败")?; + } + Ok(()) +} + +fn list(store: &CertificateStoreState) -> Result { let conn = store.open()?; + let selected_id = conn + .query_row( + "SELECT value FROM app_state WHERE key=?1", + [SELECTED], + |r| r.get(0), + ) + .optional() + .map_err(|_| "读取 BYOS 选择失败")?; let mut stmt = conn .prepare("SELECT key FROM app_state WHERE key LIKE 'byos_profile:%' ORDER BY key") .map_err(|_| "读取 BYOS 配置失败")?; let ids = stmt .query_map([], |r| r.get::<_, String>(0)) .map_err(|_| "读取 BYOS 配置失败")?; - ids.map(|id| { - load( - &store, - id.map_err(|_| "读取 BYOS 配置失败")? - .trim_start_matches(PREFIX), - ) - .map(|v| v.profile) + let profiles = ids + .map(|id| { + load( + store, + id.map_err(|_| "读取 BYOS 配置失败")? + .trim_start_matches(PREFIX), + ) + .map(|v| v.profile) + }) + .collect::, _>>()?; + Ok(Profiles { + profiles, + selected_id, }) - .collect() +} + +#[tauri::command] +pub(crate) fn list_byos_profiles( + store: tauri::State<'_, CertificateStoreState>, +) -> Result { + list(&store) +} + +#[tauri::command] +pub(crate) fn select_byos_profile( + store: tauri::State<'_, CertificateStoreState>, + id: Option, +) -> Result<(), String> { + set_selected(&store.open()?, id.as_deref()) } #[tauri::command] @@ -188,9 +242,16 @@ pub(crate) fn delete_byos_profile( store: tauri::State<'_, CertificateStoreState>, id: String, ) -> Result<(), String> { + delete(&store, &id) +} + +fn delete(store: &CertificateStoreState, id: &str) -> Result<(), String> { store .open()? - .execute("DELETE FROM app_state WHERE key=?1", [record_key(&id)?]) + .execute( + "DELETE FROM app_state WHERE key=?1 OR (key=?2 AND value=?3)", + params![record_key(id)?, SELECTED, id], + ) .map_err(|_| "删除 BYOS 配置失败")?; Ok(()) } @@ -235,7 +296,11 @@ mod tests { let raw: String = store .open() .unwrap() - .query_row("SELECT value FROM app_state", [], |r| r.get(0)) + .query_row( + "SELECT value FROM app_state WHERE key=?1", + [record_key(&profile.id).unwrap()], + |r| r.get(0), + ) .unwrap(); assert!(raw.starts_with("enc:v1:")); assert!(!raw.contains("private-build-token")); @@ -259,4 +324,54 @@ mod tests { assert!(save(&store, input(None, Some("new-token".into()))).is_err()); assert!(!store.byos_key_path().exists()); } + + #[test] + fn 重启后恢复最近选择并保留离线选择() { + let dir = tempfile::tempdir().unwrap(); + let open = || { + CertificateStoreState::new(dir.path().join("shield.db"), dir.path().join("keystores")) + }; + let store = open(); + store + .open() + .unwrap() + .execute_batch("CREATE TABLE app_state(key TEXT PRIMARY KEY,value TEXT NOT NULL)") + .unwrap(); + let input = |name: &str| ProfileInput { + id: None, + name: name.into(), + endpoint: "https://keys.example".into(), + auth_mode: "device_approval".into(), + runtime_cert: None, + api_key: Some("test-build-token".into()), + }; + let first = save(&store, input("服务一")).unwrap(); + let second = save(&store, input("服务二")).unwrap(); + drop(store); + + let reopened = open(); + let saved = list(&reopened).unwrap(); + assert_eq!(saved.profiles.len(), 2); + assert_eq!(saved.selected_id.as_deref(), Some(second.id.as_str())); + assert!(!serde_json::to_string(&saved) + .unwrap() + .contains("test-build-token")); + assert_eq!( + load(&reopened, &second.id).unwrap().api_key, + "test-build-token" + ); + + set_selected(&reopened.open().unwrap(), Some(&first.id)).unwrap(); + delete(&reopened, &second.id).unwrap(); + assert_eq!( + list(&open()).unwrap().selected_id.as_deref(), + Some(first.id.as_str()) + ); + set_selected(&reopened.open().unwrap(), None).unwrap(); + assert_eq!(list(&open()).unwrap().selected_id, None); + set_selected(&reopened.open().unwrap(), Some(&first.id)).unwrap(); + delete(&reopened, &first.id).unwrap(); + assert_eq!(list(&open()).unwrap().selected_id, None); + assert!(set_selected(&reopened.open().unwrap(), Some(&first.id)).is_err()); + } } diff --git a/apps/shield-gui/src-tauri/src/main.rs b/apps/shield-gui/src-tauri/src/main.rs index a011325..617e0ee 100644 --- a/apps/shield-gui/src-tauri/src/main.rs +++ b/apps/shield-gui/src-tauri/src/main.rs @@ -587,6 +587,7 @@ fn main() { protect_aab, protect_apk, byos::list_byos_profiles, + byos::select_byos_profile, byos::save_byos_profile, byos::test_byos_connection, byos::delete_byos_profile, diff --git a/apps/shield-gui/src/components/app/byos-settings.tsx b/apps/shield-gui/src/components/app/byos-settings.tsx index da23470..dbfe2dd 100644 --- a/apps/shield-gui/src/components/app/byos-settings.tsx +++ b/apps/shield-gui/src/components/app/byos-settings.tsx @@ -1,16 +1,21 @@ -import { useEffect, useState } from "react"; +import { useState } from "react"; import { AppButton, SelectInput, TextInput } from "@/components/app/common"; -import { api, byosRequirements, type ByosProfile, type ByosProfileInput, type ByosConnectionCheck } from "@/lib/tauri"; +import { api, byosRequirements, type ByosProfiles, type ByosProfile, type ByosProfileInput, type ByosConnectionCheck } from "@/lib/tauri"; import type { Locale } from "@/lib/i18n"; import { notifyError } from "@/lib/notify"; -export function ByosSettings({ value, onChange, locale, disabled }: { - value: ByosProfile | null; onChange: (value: ByosProfile | null) => void; locale: Locale; disabled: boolean; +export function ByosSettings({ value: { profiles, selectedId }, onChange, onSavingChange, locale, disabled }: { + value: ByosProfiles; + onChange: (value: ByosProfiles) => void; + onSavingChange: (saving: boolean) => void; + locale: Locale; + disabled: boolean; }) { const en = locale === "en"; - const [profiles, setProfiles] = useState([]); + const value = profiles.find((profile) => profile.id === selectedId) ?? null; const [draft, setDraft] = useState(null); - const [busy, setBusy] = useState(false); + const [busy, setBusyState] = useState(false); + const setBusy = (saving: boolean) => { setBusyState(saving); onSavingChange(saving); }; const [testing, setTesting] = useState(false); const [check, setCheck] = useState(null); const [checkError, setCheckError] = useState(""); @@ -24,21 +29,31 @@ export function ByosSettings({ value, onChange, locale, disabled }: { catch (error) { setCheckError(String(error)); } finally { setTesting(false); } } - useEffect(() => { let active = true; void api.listByosProfiles().then((items) => { if (active) setProfiles(items); }).catch(notifyError); return () => { active = false; }; }, []); + async function select(id: string | null) { + setBusy(true); + edit(null); + try { await api.selectByosProfile(id); onChange({ selectedId: id, profiles }); } + catch (error) { notifyError(String(error)); } + finally { setBusy(false); } + } const change = (patch: Partial) => { resetCheck(); setDraft((previous) => previous ? { ...previous, ...patch } : previous); }; async function save() { if (!draft) return; setBusy(true); try { const saved = await api.saveByosProfile(draft); - setProfiles(await api.listByosProfiles()); onChange(saved); edit(null); + const next = profiles.some((profile) => profile.id === saved.id) + ? profiles.map((profile) => profile.id === saved.id ? saved : profile) + : [...profiles, saved]; + onChange({ selectedId: saved.id, profiles: next }); + edit(null); } catch (error) { notifyError(String(error)); } finally { setBusy(false); } } async function remove() { if (!value || !window.confirm(en ? "Delete this local BYOS configuration? Published app keys are managed on your server." : "删除这套本地 BYOS 配置?已发布应用的密钥仍由你的服务端管理。")) return; setBusy(true); - try { await api.deleteByosProfile(value.id); setProfiles(await api.listByosProfiles()); onChange(null); edit(null); } + try { await api.deleteByosProfile(value.id); onChange({ selectedId: null, profiles: profiles.filter((profile) => profile.id !== value.id) }); edit(null); } catch (error) { notifyError(String(error)); } finally { setBusy(false); } } @@ -46,8 +61,9 @@ export function ByosSettings({ value, onChange, locale, disabled }: { {en ? "Key delivery" : "密钥交付"} diff --git a/apps/shield-gui/src/components/app/protect-configuration-panel.tsx b/apps/shield-gui/src/components/app/protect-configuration-panel.tsx index 5745d89..d1911c1 100644 --- a/apps/shield-gui/src/components/app/protect-configuration-panel.tsx +++ b/apps/shield-gui/src/components/app/protect-configuration-panel.tsx @@ -1,4 +1,3 @@ -import { ByosSettings } from "@/components/app/byos-settings"; import { byosRequirements, type ByosProfile } from "@/lib/tauri"; import { RuntimeUiSettings } from "@/components/app/runtime-ui-settings"; import { openFileDialog, type RuntimeUiProtection } from "@/lib/tauri"; @@ -17,7 +16,7 @@ import { t, tf, type Locale } from "@/lib/i18n"; import type { CertificateRecord } from "@/lib/tauri"; export function ProtectConfigurationPanel({ - byosProfile, onByosProfileChange, + byosProfile, byosSettings, byosLoaded, byosUnavailable, runtimeUi, onRuntimeUiChange, locale, disabled, @@ -60,7 +59,9 @@ export function ProtectConfigurationPanel({ onStart, }: { byosProfile: ByosProfile | null; - onByosProfileChange: (profile: ByosProfile | null) => void; + byosSettings: React.ReactNode; + byosLoaded: boolean; + byosUnavailable: boolean; runtimeUi: RuntimeUiProtection; onRuntimeUiChange: (value: RuntimeUiProtection) => void; locale: Locale; @@ -110,6 +111,9 @@ export function ProtectConfigurationPanel({ const customPvmPacker = Boolean(xopPvm2PackerPath.trim()); const pvmPackerAvailable = customPvmPacker || xopPvm2BuiltInAvailable; const pvmReady = pvmPackerAvailable && (xopTrueVmpPrefixes.length > 0 || Boolean(pvm2RequiredMethods.trim())) && !invalidPvmPrefix && xopPvm2JavaReady && runtimeMode === "standard"; + const keyDelivery = !byosLoaded ? (locale === "en" ? "Loading…" : "读取中…") + : byosUnavailable ? (locale === "en" ? "Saved service unavailable" : "已选服务不可用") + : byosProfile?.name ?? (locale === "en" ? "Offline" : "本地离线"); return (