diff --git a/CHANGELOG.md b/CHANGELOG.md index a82c475..c9087dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -80,6 +80,8 @@ ### Changed +- Token 缓存不再按创建日期主动失效;签名或 Profile 申请实际被服务端以 Token 失效 + 拒绝时才尝试刷新,跨天申请签名材料也会先复用现有 Token。 - CLI 现在必须显式使用 `doctor`、`inspect`、`migrate-cache`、`auth`、`devices`、 `sign`、`install` 或 `deploy` 子命令。`deploy`/`install` 必须传入非空 `--serial`;`sign` 可使用 `--serial`、可信的显式 `--device-udid`,或复用兼容的 diff --git a/README.md b/README.md index f851c74..d122cea 100644 --- a/README.md +++ b/README.md @@ -219,11 +219,12 @@ hapsign deploy --hap path/to/app-unsigned.hap --serial --json `connected=true` 的目标,优先选择 `physical_candidate=true` 的 USB 真机,再把其 `serial` 原样传给后续命令。`serial` 是 HDC 连接标识,不是签名 Profile 中的 UDID。 -`auth` 可以单独调用并持久化当天 Token。同一份 Token 缓存不绑定目标设备,在同一 +`auth` 可以单独调用并持久化 Token。同一份 Token 缓存不绑定目标设备,在同一 台运行 HapSign 的电脑上可继续给不同 HarmonyOS 手机、平板或 PC 目标签名;每台 目标设备的 Profile 仍绑定自己的 UDID,切换设备会重新申请 Profile。Token 不会在 -多台运行 HapSign 的电脑之间自动同步,也不建议手工复制缓存。`auth status` 只检查 -本地当日缓存,因此 JSON 中 `online_verified` 固定为 `false`。 +多台运行 HapSign 的电脑之间自动同步,也不建议手工复制缓存。Token 缓存不会按日期 +主动失效;只有携带 Token 的 API 请求被服务端拒绝时才会尝试刷新。`auth status` 只 +检查本地缓存,因此 JSON 中 `online_verified` 固定为 `false`。 ### Agent / 半自动仅签名 @@ -411,13 +412,13 @@ signed_haps/ ## 缓存策略 -同一天内不会重复登录;签名文件只在 bundle 和目标 UDID 都相同时复用: +Token 缓存持续复用;签名文件只在当天且 bundle 和目标 UDID 都相同时复用: -- **Token 缓存**:`/.token_cache.json`,当天可跨目标设备复用 +- **Token 缓存**:`/.token_cache.json`,不按日期失效,可跨目标设备复用 - **签名文件缓存**:`/{bundle_name}/metadata.json`,当天仅为匹配的 bundle 和设备 UDID 复用 -- 跨天自动失效,重新走完整流程 -- Token 失效时自动刷新,刷新失败才回退到重新登录 +- 跨天仅签名文件缓存失效,申请新材料时仍先复用已有 Token +- 携带 Token 的 API 请求被服务端判定失效时自动刷新,刷新失败才回退到重新登录 这些文件包含敏感信息。Windows token 缓存使用当前用户 DPAPI 保护;Linux/macOS token 缓存是权限限制为 `0o600` 的明文文件,签名私钥等材料仍需按敏感文件保护。 diff --git a/SECURITY.md b/SECURITY.md index 82608ac..d82fbb0 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -10,7 +10,7 @@ vulnerability reporting;启用后请使用仓库 Security 页的“Report a vu ## 本地敏感数据 -桌面、便携版和 CLI 默认会按应用配置在程序目录的 `signing_files/` 中保存当日 token +桌面、便携版和 CLI 默认会按应用配置在程序目录的 `signing_files/` 中保存 token 缓存、调试证书、Profile 和 `.p12` 密钥库;CLI 可用 `--state-dir` 或 `HAPSIGN_SIGNING_DIR` 覆盖。此默认值不依赖启动命令时的工作目录。这些文件已被 `.gitignore` 排除,但仍是本机敏感 diff --git a/docs/AGENT_SIGNING.md b/docs/AGENT_SIGNING.md index 4d94ebc..7bc6c0c 100644 --- a/docs/AGENT_SIGNING.md +++ b/docs/AGENT_SIGNING.md @@ -62,9 +62,10 @@ hapsign sign \ --json ``` -普通日志只写入标准错误,最终标准输出仍是单个 JSON 文档。没有当日登录/签名材料 -缓存时,命令会打开浏览器;agent 应提示用户在该浏览器中完成授权,然后继续等待 -进程退出。成功结果的 `signed_hap` 是可交付产物的绝对路径。 +普通日志只写入标准错误,最终标准输出仍是单个 JSON 文档。没有可用 Token 缓存时, +命令会打开浏览器;已有 Token 不按日期主动失效,仅在携带 Token 的 API 请求被服务端 +拒绝时尝试刷新。agent 应提示用户在浏览器中完成必要的授权,然后继续等待进程退出。 +成功结果的 `signed_hap` 是可交付产物的绝对路径。 Agent 默认应显式使用 `system_controlled`:它调用系统 Edge/Chrome,但使用隔离的临时 上下文,不复用用户 cookie、已保存密码或旧 SSO 状态,并可预授予登录页访问 loopback diff --git a/hapsign/cli.py b/hapsign/cli.py index 12648c8..1452f99 100644 --- a/hapsign/cli.py +++ b/hapsign/cli.py @@ -204,7 +204,7 @@ def _add_signing_options( parser.add_argument( "--refresh-token", action="store_true", - help="删除当日 Token 缓存并重新浏览器认证,同时刷新签名材料", + help="删除 Token 缓存并重新浏览器认证,同时刷新签名材料", ) parser.add_argument( "--refresh-signing", @@ -293,7 +293,7 @@ def build_parser() -> argparse.ArgumentParser: formatter_class=_formatter, epilog="""\ 示例: - hapsign auth --json # 有当日缓存则复用,否则打开浏览器 + hapsign auth --json # 有缓存则复用,否则打开浏览器 hapsign auth --refresh --json # 强制重新浏览器认证 hapsign auth status --json # 只检查本地缓存,不验证服务端有效性 """, @@ -353,7 +353,7 @@ def build_parser() -> argparse.ArgumentParser: description=( "为 --serial/--device-udid 对应设备生成 debug Profile 并签名;" "未指定设备时可复用兼容缓存或从当前 HDC 目标读取 UDID。" - "已有当日 Auth Token 会复用;" + "已有 Auth Token 会复用;" "同一 bundle 切换设备时会自动丢弃不匹配的 Profile 缓存。" ), formatter_class=_formatter, @@ -695,9 +695,7 @@ def _run_auth(args: argparse.Namespace) -> int: "ok": True, "command": "auth status", "message": ( - "发现当日认证缓存" - if status["authenticated"] - else "没有可用的当日认证缓存" + "发现认证缓存" if status["authenticated"] else "没有可用的认证缓存" ), "cache_format": _cache_format(str(status["cache_path"])), } diff --git a/hapsign/pipeline.py b/hapsign/pipeline.py index fa9f3c0..11cacf4 100644 --- a/hapsign/pipeline.py +++ b/hapsign/pipeline.py @@ -1,13 +1,13 @@ """登录、token 交换、签名材料申请、HAP 签名和安装的全流程编排。 -双重缓存策略(同一天内复用,避免反复登录和申请): - 1. Token 缓存:``~/.hapsign/.token_cache.json`` 存储当天登录的 token 信息, - 同账号同一天内复用,不重新登录。 +双重缓存策略(避免反复登录和申请): + 1. Token 缓存:``~/.hapsign/.token_cache.json`` 存储登录后的 token 信息, + 不按日期主动失效;仅在使用 token 的 API 请求被服务端拒绝时尝试刷新。 2. 签名文件缓存:``~/.hapsign/{bundle_name}/metadata.json`` 存储当天申请的 签名文件路径,同一天内复用,不重新申请证书/设备/Profile。 缓存失效场景: - - 跨天:token 和签名文件缓存都失效,重新登录 + 重新申请。 + - 跨天:签名文件缓存失效并重新申请;Token 缓存仍会先尝试复用。 - token 过期:缓存 token 用不了时自动刷新;刷新也失败则回退到重新登录。 - 签名文件缺失:重新申请(用缓存 token,不重新登录)。 """ @@ -184,11 +184,12 @@ def _emit_progress(self, value: int, label: str) -> None: # ── Token 缓存 ────────────────────────────────────────────── def _load_token_cache(self) -> dict | None: - """加载当天的 token 缓存。 + """加载 token 缓存。 - 条件:缓存存在、creation_date 是今天;缓存可能是 Windows DPAPI 加密格式 - 或受限权限的明文 JSON。Windows 上的明文缓存首次读取时迁移为加密格式; - 其他平台继续使用 0o600 明文缓存。解密失败视为无缓存,回退重新登录。 + 缓存不按 creation_date 主动失效;只有使用 token 的 API 请求被服务端拒绝 + 时才尝试刷新。缓存可能是 Windows DPAPI 加密格式或受限权限的明文 JSON。 + Windows 上的明文缓存首次读取时迁移为加密格式;其他平台继续使用 0o600 + 明文缓存。解密失败视为无缓存,回退重新登录。 """ if not os.path.exists(self._token_cache_path): return None @@ -226,9 +227,6 @@ def _load_token_cache(self) -> dict | None: logger.warning("[cache] token 缓存格式无效,将重新登录") return None - if cache.get("creation_date") != date.today().isoformat(): - logger.info("[cache] token 缓存非今日,跳过") - return None if ( not cache.get("access_token") or not cache.get("user_id") @@ -484,7 +482,7 @@ def _run_pipeline(self) -> bool: else: token_cached = None if token_cached: - logger.info("[cache] 当天 token 已缓存,跳过登录") + logger.info("[cache] token 已缓存,跳过登录") self._init_client_from_cache(token_cached) else: # 没有缓存 token,需要登录 @@ -610,7 +608,7 @@ def _step_exchange_token(self) -> bool: self._provision_api = ProvisionAPI(self._client) self._capability_api = CapabilityAPI(self._client) - # 保存 token 缓存,供同一天内复用 + # 保存 token 缓存,后续持续复用,直到服务端拒绝后触发刷新 return self._save_token_cache() def authenticate(self, force_refresh: bool = False) -> dict[str, object]: diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 405b7df..c76b366 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -166,14 +166,17 @@ def test_custom_state_dir_owns_token_cache(tmp_path, monkeypatch) -> None: assert not (tmp_path / "signing_files" / ".token_cache.json").exists() -def test_expired_token_cache_is_ignored(tmp_path, monkeypatch) -> None: +def test_older_token_cache_is_reused_until_server_rejects( + tmp_path, monkeypatch +) -> None: pipeline = _pipeline(tmp_path, monkeypatch) cache_path = tmp_path / "signing_files" / ".token_cache.json" cache_path.parent.mkdir() + creation_date = (date.today() - timedelta(days=1)).isoformat() cache_path.write_text( json.dumps( { - "creation_date": (date.today() - timedelta(days=1)).isoformat(), + "creation_date": creation_date, "access_token": "access", "user_id": "user", "jwt_token": "jwt", @@ -182,7 +185,11 @@ def test_expired_token_cache_is_ignored(tmp_path, monkeypatch) -> None: encoding="utf-8", ) - assert pipeline._load_token_cache() is None + cache = pipeline._load_token_cache() + + assert cache is not None + assert cache["creation_date"] == creation_date + assert cache["access_token"] == "access" def test_token_cache_without_jwt_is_ignored(tmp_path, monkeypatch) -> None: @@ -279,8 +286,9 @@ def test_authenticate_reuses_token_without_browser_or_device( tmp_path, monkeypatch ) -> None: pipeline = _pipeline(tmp_path, monkeypatch) + creation_date = (date.today() - timedelta(days=1)).isoformat() cache = { - "creation_date": date.today().isoformat(), + "creation_date": creation_date, "access_token": "access", "refresh_token": "refresh", "user_id": "user", @@ -305,7 +313,7 @@ def test_authenticate_reuses_token_without_browser_or_device( assert result == { "authenticated": True, "from_cache": True, - "creation_date": date.today().isoformat(), + "creation_date": creation_date, } init_client.assert_called_once_with(cache)