可以在前端完成在线签发工作流,但浏览器不执行密码学签名。前端只调用受权控制面 API;私钥始终留在 Azure Key Vault 或 Managed HSM,独立 BitzOrcas.LicenseSigner 宿主只请求 KMS/HSM 完成 ES256 签名。
当前已经实现的边界
当前“在线签发”包括:
- 前端创建申请、审批/驳回、触发签发、查看后台状态、下载和吊销;
- 没有客户 Runtime License 时,最低身份会话、首次登录强制改密、密码恢复、按权限过滤的应用导航和受权限保护的 LicenseManagement 控制面仍可运行,避免首次签发循环依赖;
- API 以持久租约异步调用独立签名宿主;
- 签名宿主使用版本化 Azure 密钥 URI;
- API 用独立配置的可信公钥复验签名,并逐字段确认签名宿主只增加了
signature; - 同一幂等操作可在网络或进程故障后安全恢复。
当前不包括客户匿名自助签发、浏览器持有私钥、前端直连 KMS/HSM,也不包括一个公开的客户在线 Lease 下载服务。正式 Envelope 目前由受权控制面下载,再经受控渠道分发;Runtime 也支持另行实现 ILicenseLeaseSource 的在线续租 Adapter。
1. 准备 Azure 密钥
为每次轮换创建一个不可变密钥版本,并保存三项信息:
| 项目 | 示例 | 是否保密 |
|---|---|---|
KeyId | key-2026-01 | 否,稳定协议标识 |
| 版本化密钥 URI | https://vault.managedhsm.azure.net/keys/license-key/<version> | 否,但应受控 |
| ES256 公钥 PEM | -----BEGIN PUBLIC KEY-----… | 否,分发给验证端 |
URI 必须包含具体版本;不能使用隐式 latest。签名宿主运行身份只授予读取密钥元数据和签名所需的最小权限,不能导出私钥。API 使用的公钥必须通过独立可信渠道取得,不能把 KMS 返回值当作唯一信任来源。
本地第一次创建 P-256 ES256 密钥可以使用 Azure CLI:
az loginaz keyvault key create \ --vault-name <vault-name> \ --name bitzorcas-runtime-license \ --kty EC \ --curve P-256 \ --ops sign verify
# 保存输出的完整、版本化 kid。az keyvault key show \ --vault-name <vault-name> \ --name bitzorcas-runtime-license \ --query key.kid \ --output tsv
# <version> 是上一步 kid 的最后一段。az keyvault key download \ --vault-name <vault-name> \ --name bitzorcas-runtime-license \ --version <version> \ --encoding PEM \ --file bitzorcas-runtime-license-public.pemKey Vault 数据面优先使用 Azure RBAC。本地运行 LicenseSigner 的 Azure CLI 身份可授予 Key Vault Crypto User,生产改用独立 Managed Identity;创建/轮换密钥的管理身份与日常签名身份应分离。参数与身份模型可核对 Microsoft Learn:az keyvault key 和 Microsoft Learn:Key Vault authentication。
2. 配置本地 AppHost
先完成本地基础设施中的 SQL Server、RabbitMQ、Redis、JWT 和 AppHost 参数。然后把开发签名配置写入 AppHost user-secrets:
# 固定控制面使用的协议 KeyId 和不可变 KMS 密钥版本。dotnet user-secrets set \ "LicenseManagement:Development:KeyId" \ "key-2026-01" \ --project src/Hosts/BitzOrcas.AppHost
dotnet user-secrets set \ "LicenseManagement:Development:VersionedKeyUri" \ "https://<vault>.managedhsm.azure.net/keys/<name>/<version>" \ --project src/Hosts/BitzOrcas.AppHost
# 从独立可信渠道配置对应公钥,并显式开启后台工作器。dotnet user-secrets set \ "LicenseManagement:Development:TrustedPublicKeyPem" \ "$(cat bitzorcas-runtime-license-public.pem)" \ --project src/Hosts/BitzOrcas.AppHost
dotnet user-secrets set \ "LicenseManagement:Development:SigningWorkerEnabled" \ "true" \ --project src/Hosts/BitzOrcas.AppHost如需覆盖开发控制面租户:
# 生产环境必须换成独立的厂商控制面租户。dotnet user-secrets set \ "LicenseManagement:Development:ControlPlaneTenantId" \ "1000001" \ --project src/Hosts/BitzOrcas.AppHostAppHost 会生成并持久化 API 与签名宿主共用的开发服务凭据,不需要开发者复制 Token。DefaultAzureCredential 已禁用交互式浏览器回退;本地可使用已登录的 Azure CLI/IDE 身份,生产使用工作负载身份。
3. 启动并检查
scripts/local/bootstrap.sh aspirescripts/local/doctor.sh aspiredotnet run --project src/Hosts/BitzOrcas.AppHostdoctor.sh aspire 不打印密钥或密码,只报告签发端是否缺少版本化密钥 URI、可信公钥、工作器开关,以及
本机 DeploymentId 和 Runtime License Envelope 是否已经存在。缺少签发端配置只会让在线签发失败关闭;
缺少 Envelope 时,community.small.v1 在人数不超过 30 的边界内继续运行;未选择该策略、计量不可用或已经超限时,普通业务才会被 Runtime License 拦截。
如果这是空的 Development 演示库,需要 admin/operator 账号验证四眼流程,请把启动命令改为:
BITZORCAS_ASPIRE_SEED_DEMO=true dotnet run --project src/Hosts/BitzOrcas.AppHost前置 bootstrap.sh aspire 会在 secret 缺失时为十个演示账号生成一个仅限本机 Development 的 10 字符
初始密码,并把登录提示写到被 gitignore 的 .bitzorcas/demo-credentials(权限 0600)。
默认不强制首次改密。另开终端查看:
cat .bitzorcas/demo-credentials使用其中的租户 1000001、admin 和 operator 登录。初始明文只会注入一次性 schema initializer,
写库前立即转换成 BCrypt,常驻 API/JobHost 不接收该值;重复 seed 也不会覆盖已经修改过的账号密码。
重启 AppHost 不会轮换该 secret。旧 44 字符值的轮换步骤见
演示数据与种子参考。
显式 AppHost demo seed 只在该一次性进程中用高优先级空白配置遮蔽 API
user-secrets 里可能遗留的 USER:*:PASSWORD_HASH,避免旧哈希覆盖新凭据提示;原 secret 不会被删除,
非 Aspire seed 仍优先使用预计算哈希。
用户角色 CSV 使用可读的 UserName 作为种子输入。写入授权表前,Identity adapter 会把用户名解析为
不可变用户 ID,并一次性校验所有用户和角色引用;任一引用无效时整步拒绝写入,不会留下部分角色绑定。
AppHost 会按顺序完成业务 schema、Quartz schema,再启动 API 和 JobHost。它同时给 API/JobHost 注入正式 Runtime adapter,使用同一部署身份和离线信封:
.bitzorcas/license/deployment-id.bitzorcas/license/runtime-license.jsonAPI 第一次启动会原子创建 DeploymentId。另开终端读取:
cat .bitzorcas/license/deployment-id然后检查:
- Dashboard 中
schema-initializer和quartz-schema-initializer成功退出; api、jobhost和license-signer处于 Running;- 使用拥有
license-management.runtime-license.view权限的账号打开签名网关状态; - 状态为 ready 后再触发签发。
/health/live 只证明签名宿主进程存活。/health/ready 还会验证配置、KMS 和回执目录,并要求服务间 Bearer;日常使用控制面“签名网关状态”查看,不要把 Bearer 放进浏览器或手工 curl 历史。
4. 在前端完成四眼工作流
管理页面:
/license-management/runtime-licenses该页面已经在仓库 frontend/apps/app 中实现,并通过 Platform SDK 调用下列控制面 API;它不是待开发的
概念页。首次登录若要求改密,应先完成改密再进入该页面。没有 Runtime License 时,未认证调用会得到
Authentication.Required,已认证但权限不足会得到授权拒绝;不应先被
Licensing.Runtime.Unavailable 拦截。
显式执行 Development demo seed 后,控制面默认绑定租户 1000001:
admin/root-admin:创建、签发、下载、吊销;operator:查看、审批、驳回。
完整流程:
admin创建申请;operator审批;申请人与审批人不能相同;admin触发签发;- 页面观察
Pending → Processing → Completed; - 状态成为
Issued后下载 Envelope,并核对页面显示的 SHA-256; - 经受控渠道交付给客户。
若申请不合格,operator 填写原因后驳回,状态进入 Rejected,不能在原记录上修改后继续。应重新创建申请。
5. 填写申请
| 字段 | 操作要求 |
|---|---|
| CustomerId | 客户稳定标识,不是控制面租户 |
| ProductId | 必须在签名宿主 AllowedProductIds 中 |
| Edition / Features | 与合同和交付 Profile 一致 |
| VersionRange | 明确的允许版本范围 |
| TenancyModes / Environments | 只包含实际授权范围 |
| DeploymentLimit | 1 到 100000 |
| NotBefore / ExpiresAt | 业务有效窗口 |
| OfflineUntil / GraceUntil | 满足 NotBefore ≤ OfflineUntil ≤ GraceUntil |
| DeploymentId | 客户持久部署身份;不能使用 Pod/MAC/CPU |
| KeyId | 与版本化 KMS URI和可信公钥目录完全一致 |
Protocol v1 用于专属部署。Protocol v2 当前只允许 community.web.v1,前端会固定其 Profile、Composition 和共享 DeploymentId;后端仍做最终校验。
完整产品本地调试的推荐值
| 字段 | 值 |
|---|---|
| ProductId | bitzorcas-modern |
| Edition | Enterprise |
| Features | framework.core, framework.aspnetcore, framework.infrastructure, workflow.runtime |
| VersionRange | [1.0.0,2.0.0) |
| TenancyModes | multi-tenant |
| Environments | development |
| DeploymentLimit | 1 |
| DeploymentId | .bitzorcas/license/deployment-id 的文件内容 |
| KeyId | AppHost 配置的稳定别名,例如 key-2026-01 |
| ProtocolVersion | 1 |
时间窗口至少满足 NotBefore ≤ ExpiresAt、NotBefore ≤ OfflineUntil ≤ GraceUntil,并覆盖计划调试周期。申请提交后载荷不可修改;填错时应重新创建。
如何取得客户 DeploymentId
客户先配置一个持久 Licensing:Runtime:DeploymentIdentityPath。Runtime 首次访问该存储时会原子创建 32 位小写 GUID 文本。客户把该 ID 通过受控渠道提供给签发方;文件本身必须保留在 PV/持久 Secret 中。
不要在签发后删除或重新生成该文件,否则许可证会因部署身份不匹配而变为 Invalid。
6. 后台签发与重试
“签发”按钮不会同步等待 KMS。API 先在短事务中冻结请求和幂等事实,后台工作器再:
- 领取数据库租约;
- 在事务外调用签名宿主;
- 取得或恢复持久签名回执;
- 用可信公钥复验并比对完整载荷;
- 在新事务中提交 Envelope。
暂时故障按有界退避自动重试。连续八次仍失败或持久载荷损坏时进入 Failed,页面显示脱敏稳定错误码。排除 KMS、网络、回执卷或配置问题后,由操作者重新确认并排队;不要直接改数据库状态。
7. 生产配置
API/控制面:
LicenseManagement:ControlPlane:TenantId=<独立厂商控制面租户>LicenseManagement:Signing:BaseUrl=https://license-signer.internalLicenseManagement:Signing:SignPath=/v1/licenses/signLicenseManagement:Signing:ReadinessPath=/health/readyLicenseManagement:Signing:Provider=production-kmsLicenseManagement:Signing:TimeoutSeconds=10LicenseManagement:Signing:TrustedPublicKeys:<key-id>=<PEM 公钥>LicenseManagement:Signing:BearerToken=<短期服务凭据>LicenseManagement:SigningWorker:Enabled=trueLicenseManagement:SigningWorker:PollIntervalSeconds=5LicenseManagement:SigningWorker:BatchSize=10LicenseManagement:SigningWorker:LeaseSeconds=90独立签名宿主:
LicenseSigner:AllowedProductIds:0=bitzorcas-modernLicenseSigner:Authentication:BearerToken=<与 API 当前值一致>LicenseSigner:Authentication:PreviousBearerToken=<仅轮换窗口可选>LicenseSigner:AzureKeyVault:Keys:<key-id>=https://<vault>/keys/<name>/<version>LicenseSigner:ReceiptStore:Directory=/var/lib/bitzorcas/license-signing-receipts生产要求:
- API、签名宿主和 KMS 位于受控私有网络;
- 控制面租户不能沿用普通业务租户;
- 前端权限按创建、审批、签发、下载、吊销拆分;
- 回执目录使用专用持久卷;多副本共享支持原子创建的存储,否则限制为单副本;
- Bearer、工作负载身份和 KMS 审计进入 Secret/安全运维系统;
- 私钥、Bearer 和签名请求原文不进入日志。
8. Bearer 与密钥轮换
服务凭据轮换顺序:
- 签名宿主配置新
BearerToken,旧值暂放PreviousBearerToken; - 更新全部 API 实例,只发送新值;
- 确认旧值不再使用;
- 删除
PreviousBearerToken。
签名密钥轮换顺序:
- 创建新不可变密钥版本和新
KeyId; - 先向 API/Runtime 分发新公钥;
- 确认所有目标实例信任新 KeyId;
- 开始用新 KeyId 签发;
- 等旧 License 完成换发或合同窗口结束后再移除旧公钥。
9. 下载与客户启用
下载结果包含建议文件名、JSON Envelope 和 SHA-256。通过认证门户、企业文件交换或等价受控渠道交付;不要把 License 原文贴进公开工单。
本仓库本地 AppHost 的激活方式:
mkdir -p .bitzorcas/licensecp <下载的许可证文件> .bitzorcas/license/runtime-license.json随后重启 API/JobHost。Runtime 只在启动时导入显式离线文件,再将通过验签的信封写入各自缓存;单纯下载但不放入固定路径不会激活。直接 dotnet run API 或 JobHost 时,Development appsettings 已使用同一仓库相对路径,但仍需在对应 Host 的 user-secrets/环境变量中提供 Licensing:Runtime:TrustedPublicKeys:<key-id>。
客户 Runtime 最小配置:
Licensing:Runtime:Enabled=trueLicensing:Runtime:ProductId=bitzorcas-modernLicensing:Runtime:ProductVersion=<部署版本>Licensing:Runtime:Environment=<授权环境>Licensing:Runtime:TenancyMode=<授权租户模式>Licensing:Runtime:DeploymentIdentityPath=/var/lib/bitzorcas/license/deployment-idLicensing:Runtime:CachePath=/var/lib/bitzorcas/license/runtime-license.jsonLicensing:Runtime:OfflineLicensePath=/run/secrets/bitzorcas-license.jsonLicensing:Runtime:TrustedPublicKeys:<key-id>=<PEM 公钥>启动后检查 /health/license。只有签名、上下文、DeploymentId 和时间窗口全部匹配才会进入 Valid;复制文件不等于激活成功。
10. 吊销
只有拥有 license-management.runtime-license.revoke 权限的操作者可以吊销。填写原因并确认后,控制面通过同一 KMS 重新签发 Revoked=true 的可验证 Envelope。将该撤销结果交付到目标 Runtime 或在线 Lease Source 后,readiness 变为 Unhealthy,业务执行失败关闭。
删除数据库记录或客户本地文件不是可验证吊销。
故障速查
| 状态/错误 | 处理 |
|---|---|
license-signer.not-configured | 检查 AllowedProductIds、Bearer、版本化 URI、回执目录 |
provider-unavailable | 检查工作负载身份、KMS 网络和 get/sign 权限 |
receipt-store-unavailable | 检查专用卷、权限和多副本共享语义 |
invalid-request | 检查 KeyId、排序集合、时间轴、协议组合和部署上限 |
idempotency-conflict | 同一操作键绑定了不同载荷;停止重试并调查调用方 |
操作长期 RetryScheduled | 检查网关 readiness、工作器开关和下次重试时间 |
Runtime Invalid | 核对公钥、产品/版本/环境/tenancy、DeploymentId 和文件完整性 |