Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,或复用兼容的
Expand Down
15 changes: 8 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,11 +219,12 @@ hapsign deploy --hap path/to/app-unsigned.hap --serial <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 / 半自动仅签名

Expand Down Expand Up @@ -411,13 +412,13 @@ signed_haps/

## 缓存策略

同一天内不会重复登录;签名文件只在 bundle 和目标 UDID 都相同时复用:
Token 缓存持续复用;签名文件只在当天且 bundle 和目标 UDID 都相同时复用:

- **Token 缓存**:`<state-dir>/.token_cache.json`,当天可跨目标设备复用
- **Token 缓存**:`<state-dir>/.token_cache.json`,不按日期失效,可跨目标设备复用
- **签名文件缓存**:`<state-dir>/{bundle_name}/metadata.json`,当天仅为匹配的
bundle 和设备 UDID 复用
- 跨天自动失效,重新走完整流程
- Token 失效时自动刷新,刷新失败才回退到重新登录
- 跨天仅签名文件缓存失效,申请新材料时仍先复用已有 Token
- 携带 Token 的 API 请求被服务端判定失效时自动刷新,刷新失败才回退到重新登录

这些文件包含敏感信息。Windows token 缓存使用当前用户 DPAPI 保护;Linux/macOS
token 缓存是权限限制为 `0o600` 的明文文件,签名私钥等材料仍需按敏感文件保护。
Expand Down
2 changes: 1 addition & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 排除,但仍是本机敏感
Expand Down
7 changes: 4 additions & 3 deletions docs/AGENT_SIGNING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 4 additions & 6 deletions hapsign/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 # 只检查本地缓存,不验证服务端有效性
""",
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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"])),
}
Expand Down
24 changes: 11 additions & 13 deletions hapsign/pipeline.py
Original file line number Diff line number Diff line change
@@ -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,不重新登录)。
"""
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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,需要登录
Expand Down Expand Up @@ -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]:
Expand Down
18 changes: 13 additions & 5 deletions tests/test_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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:
Expand Down Expand Up @@ -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",
Expand All @@ -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)

Expand Down
Loading