diff --git a/product-source/guanghu-knowledge-base/server/domain-access-orchestrator.test.ts b/product-source/guanghu-knowledge-base/server/domain-access-orchestrator.test.ts index e2e53af..7e145c6 100644 --- a/product-source/guanghu-knowledge-base/server/domain-access-orchestrator.test.ts +++ b/product-source/guanghu-knowledge-base/server/domain-access-orchestrator.test.ts @@ -139,6 +139,14 @@ function validHandoff() { const payload = { domainId: DOMAIN_ID, repositoryId: 'REPO-014', + runtimePolicy: { + allowedSessionScopes: ['domain:enter', 'knowledge:read'], + forbiddenDataScopes: ['private:relationship-core'], + permissionPolicyRef: 'policy://origin-domain/default', + routeRef: 'domain-route://origin-domain/runtime', + themeOwner: 'fifth-domain', + themePackageRef: 'theme://origin-domain/lake-reflects-stars', + }, schema: 'gh-aios.domain-manifest/v1', signerId: 'GH-LIGHTHOUSE-001', sourceCommit: 'b'.repeat(40), diff --git a/product-source/guanghu-knowledge-base/server/domain-access.test.ts b/product-source/guanghu-knowledge-base/server/domain-access.test.ts index 7f2949b..93c288a 100644 --- a/product-source/guanghu-knowledge-base/server/domain-access.test.ts +++ b/product-source/guanghu-knowledge-base/server/domain-access.test.ts @@ -19,6 +19,9 @@ const COMMIT = 'b'.repeat(40); const NODE_TYPE = 'local-terminal' as const; const { privateKey, publicKey } = generateKeyPairSync('ed25519'); function trustedSignerFromRegistry(overrides: Record = {}): TrustedManifestSigner { + const domainId = Array.isArray(overrides.domainIds) && typeof overrides.domainIds[0] === 'string' + ? overrides.domainIds[0] + : 'DOM-FIFTH-0001'; const parsed = parseTrustedManifestSignerRegistry({ registryId: 'GH-AIOS-TRUSTED-DOMAIN-MANIFEST-SIGNERS-001', schema: 'gh-aios.trusted-domain-manifest-signers/v1', @@ -39,7 +42,7 @@ function trustedSignerFromRegistry(overrides: Record = {}): Tru sourceUrl: 'https://guanghulab.com/code/bingshuo/guanghu-ice-heart', }); const signer = resolveTrustedManifestSigner(parsed, { - domainId: 'DOM-FIFTH-0001', + domainId, repositoryId: String(overrides.repositoryId ?? 'REPO-014'), signerId: 'GH-LIGHTHOUSE-001', }); @@ -50,6 +53,14 @@ const trustedSigner = trustedSignerFromRegistry(); const manifestPayload = { domainId: 'DOM-FIFTH-0001', repositoryId: 'REPO-014', + runtimePolicy: { + allowedSessionScopes: ['domain:enter', 'knowledge:read'], + forbiddenDataScopes: ['private:relationship-core'], + permissionPolicyRef: 'policy://origin-domain/default', + routeRef: 'domain-route://origin-domain/runtime', + themeOwner: 'fifth-domain', + themePackageRef: 'theme://origin-domain/lake-reflects-stars', + }, schema: 'gh-aios.domain-manifest/v1', signerId: 'GH-LIGHTHOUSE-001', sourceCommit: COMMIT, @@ -338,6 +349,76 @@ test('manifest tampering and signatures from another key fail closed', () => { }, 'DOM-FIFTH-0001', 'LOCAL-001', NODE_TYPE, trustedSigner, NOW), /domain_access_handoff_invalid/); }); +test('the signed runtime policy rejects theme ownership, unknown fields and scope escalation', () => { + assert.equal(Object.isFrozen(verifiedHandoff.manifest.runtimePolicy), true); + assert.equal(Object.isFrozen(verifiedHandoff.manifest.runtimePolicy.allowedSessionScopes), true); + + assert.throws(() => parseDomainAccessHandoff({ + ...validHandoff, + manifest: { + ...validHandoff.manifest, + runtimePolicy: { ...validHandoff.manifest.runtimePolicy, themeOwner: 'domain-team' }, + }, + }, 'DOM-FIFTH-0001', 'LOCAL-001', NODE_TYPE, trustedSigner, NOW), /domain_access_handoff_invalid/); + + assert.throws(() => parseDomainAccessHandoff({ + ...validHandoff, + manifest: { + ...validHandoff.manifest, + runtimePolicy: { ...validHandoff.manifest.runtimePolicy, decorativeOverride: true }, + }, + }, 'DOM-FIFTH-0001', 'LOCAL-001', NODE_TYPE, trustedSigner, NOW), /domain_access_handoff_invalid/); + + assert.throws(() => parseDomainAccessHandoff({ + ...validHandoff, + sessionCapability: { ...validHandoff.sessionCapability, scopes: ['domain:enter', 'relationship:private'] }, + }, 'DOM-FIFTH-0001', 'LOCAL-001', NODE_TYPE, trustedSigner, NOW), /domain_access_handoff_invalid/); +}); + +test('runtime policy lists must be explicit, unique and bound by the signature', () => { + for (const runtimePolicy of [ + { ...validHandoff.manifest.runtimePolicy, allowedSessionScopes: [] }, + { ...validHandoff.manifest.runtimePolicy, allowedSessionScopes: ['domain:enter', 'domain:enter'] }, + { ...validHandoff.manifest.runtimePolicy, forbiddenDataScopes: [] }, + ]) { + assert.throws(() => parseDomainAccessHandoff({ + ...validHandoff, + manifest: { ...validHandoff.manifest, runtimePolicy }, + }, 'DOM-FIFTH-0001', 'LOCAL-001', NODE_TYPE, trustedSigner, NOW), /domain_access_handoff_invalid/); + } + + assert.throws(() => parseDomainAccessHandoff({ + ...validHandoff, + manifest: { + ...validHandoff.manifest, + runtimePolicy: { ...validHandoff.manifest.runtimePolicy, routeRef: 'domain-route://origin-domain/other' }, + }, + }, 'DOM-FIFTH-0001', 'LOCAL-001', NODE_TYPE, trustedSigner, NOW), /domain_access_handoff_invalid/); +}); + +test('an enterprise domain cannot reuse the Fifth Domain theme namespace', () => { + const enterpriseSigner = trustedSignerFromRegistry({ domainIds: ['DOM-PRIMARY-0001'] }); + const enterprisePayload = { + ...manifestPayload, + domainId: 'DOM-PRIMARY-0001', + runtimePolicy: { + ...manifestPayload.runtimePolicy, + themeOwner: 'domain-team' as const, + }, + }; + const enterpriseBytes = domainManifestSigningBytes(enterprisePayload); + const enterpriseDigest = createHash('sha256').update(enterpriseBytes).digest('hex'); + assert.throws(() => parseDomainAccessHandoff({ + connectionReceipt: { ...validHandoff.connectionReceipt, domainId: enterprisePayload.domainId, manifestDigest: enterpriseDigest }, + manifest: { + digest: enterpriseDigest, + ...enterprisePayload, + signature: sign(null, enterpriseBytes, privateKey).toString('base64'), + }, + sessionCapability: { ...validHandoff.sessionCapability, domainId: enterprisePayload.domainId }, + }, enterprisePayload.domainId, 'LOCAL-001', NODE_TYPE, enterpriseSigner, NOW), /domain_access_handoff_invalid/); +}); + test('trusted signer identity and repository are external inputs, not payload authority', () => { assert.throws(() => parseDomainAccessHandoff( validHandoff, diff --git a/product-source/guanghu-knowledge-base/server/domain-access.ts b/product-source/guanghu-knowledge-base/server/domain-access.ts index 3223430..5974f7b 100644 --- a/product-source/guanghu-knowledge-base/server/domain-access.ts +++ b/product-source/guanghu-knowledge-base/server/domain-access.ts @@ -14,14 +14,27 @@ import { const MANIFEST_SCHEMA = 'gh-aios.domain-manifest/v1' as const; const SHA256_PATTERN = /^[a-f0-9]{64}$/; const COMMIT_PATTERN = /^[a-f0-9]{40}(?:[a-f0-9]{24})?$/; +const POLICY_SCOPE_PATTERN = /^[a-z][a-z0-9-]*(?::[a-z][a-z0-9-]*)?$/; +const POLICY_REFERENCE_PATTERN = /^[a-z][a-z0-9+.-]*:\/\/[A-Za-z0-9._~:/-]{1,240}$/; const cryptographicallyVerifiedManifests = new WeakSet(); export type DomainNodeType = 'local-terminal' | 'cloud-resident'; +export type DomainThemeOwner = 'domain-team' | 'fifth-domain'; + +export interface DomainRuntimeManifestPolicy { + allowedSessionScopes: readonly string[]; + forbiddenDataScopes: readonly string[]; + permissionPolicyRef: string; + routeRef: string; + themeOwner: DomainThemeOwner; + themePackageRef: string; +} export interface DomainManifestEvidence { digest: string; domainId: string; repositoryId: string; + runtimePolicy: DomainRuntimeManifestPolicy; schema: typeof MANIFEST_SCHEMA; signature: string; signerId: string; @@ -38,6 +51,7 @@ export interface DomainManifestEvidence { export interface DomainManifestSignedPayload { domainId: string; repositoryId: string; + runtimePolicy: DomainRuntimeManifestPolicy; schema: typeof MANIFEST_SCHEMA; signerId: string; sourceCommit: string; @@ -108,6 +122,18 @@ function isIdentifier(value: unknown): value is string { return typeof value === 'string' && value.length > 0 && value.length <= 160; } +function isPolicyReference(value: unknown): value is string { + return typeof value === 'string' && POLICY_REFERENCE_PATTERN.test(value); +} + +function isPolicyScopeList(value: unknown): value is string[] { + return Array.isArray(value) + && value.length > 0 + && value.length <= 64 + && value.every((scope) => typeof scope === 'string' && POLICY_SCOPE_PATTERN.test(scope)) + && new Set(value).size === value.length; +} + function invalidHandoff(): never { throw new Error('domain_access_handoff_invalid'); } @@ -116,6 +142,14 @@ export function domainManifestSigningBytes(payload: DomainManifestSignedPayload) return Buffer.from(JSON.stringify({ domainId: payload.domainId, repositoryId: payload.repositoryId, + runtimePolicy: { + allowedSessionScopes: payload.runtimePolicy.allowedSessionScopes, + forbiddenDataScopes: payload.runtimePolicy.forbiddenDataScopes, + permissionPolicyRef: payload.runtimePolicy.permissionPolicyRef, + routeRef: payload.runtimePolicy.routeRef, + themeOwner: payload.runtimePolicy.themeOwner, + themePackageRef: payload.runtimePolicy.themePackageRef, + }, schema: payload.schema, signerId: payload.signerId, sourceCommit: payload.sourceCommit, @@ -140,9 +174,13 @@ export function parseDomainAccessHandoff( const manifest = input.manifest; const capability = input.sessionCapability; const connection = input.connectionReceipt; - if (!isRecord(manifest) || !hasExactKeys(manifest, ['digest', 'domainId', 'repositoryId', 'schema', 'signature', 'signerId', 'sourceCommit'])) invalidHandoff(); + if (!isRecord(manifest) || !hasExactKeys(manifest, ['digest', 'domainId', 'repositoryId', 'runtimePolicy', 'schema', 'signature', 'signerId', 'sourceCommit'])) invalidHandoff(); if (!isRecord(capability) || !hasExactKeys(capability, ['capabilityId', 'domainId', 'expiresAt', 'nodeId', 'nodeType', 'scopes'])) invalidHandoff(); if (!isRecord(connection) || !hasExactKeys(connection, ['connectionId', 'domainId', 'manifestDigest', 'nodeId', 'nodeType', 'receiptId', 'state'])) invalidHandoff(); + const runtimePolicy = manifest.runtimePolicy; + if (!isRecord(runtimePolicy) || !hasExactKeys(runtimePolicy, ['allowedSessionScopes', 'forbiddenDataScopes', 'permissionPolicyRef', 'routeRef', 'themeOwner', 'themePackageRef'])) invalidHandoff(); + const allowedSessionScopes = runtimePolicy.allowedSessionScopes; + const forbiddenDataScopes = runtimePolicy.forbiddenDataScopes; const digest = manifest.digest; if (manifest.schema !== MANIFEST_SCHEMA @@ -156,9 +194,21 @@ export function parseDomainAccessHandoff( || typeof manifest.sourceCommit !== 'string' || !COMMIT_PATTERN.test(manifest.sourceCommit) || typeof manifest.signature !== 'string') invalidHandoff(); + if (!isPolicyScopeList(allowedSessionScopes) + || !allowedSessionScopes.includes('domain:enter') + || !isPolicyScopeList(forbiddenDataScopes) + || !isPolicyReference(runtimePolicy.permissionPolicyRef) + || !isPolicyReference(runtimePolicy.routeRef) + || !isPolicyReference(runtimePolicy.themePackageRef) + || (runtimePolicy.themeOwner !== 'domain-team' && runtimePolicy.themeOwner !== 'fifth-domain') + || (expectedDomainId === 'DOM-FIFTH-0001' + ? runtimePolicy.themeOwner !== 'fifth-domain' || !runtimePolicy.themePackageRef.startsWith('theme://origin-domain/') + : runtimePolicy.themeOwner !== 'domain-team' || runtimePolicy.themePackageRef.startsWith('theme://origin-domain/'))) invalidHandoff(); + const signedPayload = domainManifestSigningBytes({ domainId: manifest.domainId, repositoryId: manifest.repositoryId, + runtimePolicy: runtimePolicy as unknown as DomainRuntimeManifestPolicy, schema: manifest.schema, signerId: manifest.signerId, sourceCommit: manifest.sourceCommit, @@ -180,7 +230,9 @@ export function parseDomainAccessHandoff( || typeof capability.expiresAt !== 'number' || !Number.isSafeInteger(capability.expiresAt) || capability.expiresAt <= now || !Array.isArray(capability.scopes) || capability.scopes.some((scope) => !isIdentifier(scope)) - || !capability.scopes.includes('domain:enter')) invalidHandoff(); + || new Set(capability.scopes).size !== capability.scopes.length + || !capability.scopes.includes('domain:enter') + || capability.scopes.some((scope) => !allowedSessionScopes.includes(scope))) invalidHandoff(); if (!isIdentifier(connection.connectionId) || !isIdentifier(connection.receiptId) @@ -193,7 +245,21 @@ export function parseDomainAccessHandoff( const handoff: DomainAccessHandoff = { connectionReceipt: connection as unknown as DomainConnectionReceipt, manifest: { - ...(manifest as unknown as Omit), + digest, + domainId: manifest.domainId as string, + repositoryId: manifest.repositoryId as string, + runtimePolicy: Object.freeze({ + allowedSessionScopes: Object.freeze([...allowedSessionScopes]), + forbiddenDataScopes: Object.freeze([...forbiddenDataScopes]), + permissionPolicyRef: runtimePolicy.permissionPolicyRef as string, + routeRef: runtimePolicy.routeRef as string, + themeOwner: runtimePolicy.themeOwner as DomainThemeOwner, + themePackageRef: runtimePolicy.themePackageRef as string, + }), + schema: manifest.schema, + signature: manifest.signature, + signerId: manifest.signerId as string, + sourceCommit: manifest.sourceCommit, verifierReceipt: { manifestDigest: digest, signerId: manifest.signerId, @@ -265,7 +331,8 @@ export function evaluateDomainAccess(evidence: DomainAccessEvidence, now = Date. || capability.nodeId !== evidence.nodeId || capability.nodeType !== evidence.nodeType || capability.expiresAt <= now - || !capability.scopes.includes('domain:enter')) { + || !capability.scopes.includes('domain:enter') + || capability.scopes.some((scope) => !manifest?.runtimePolicy.allowedSessionScopes.includes(scope))) { blockers.push('scoped_session_capability_missing'); } const receipt = evidence.connectionReceipt; diff --git a/product-source/hololake-platform/architecture/HOLOLAKE-DOMAIN-MANIFEST-RUNTIME-POLICY-CONTRACT-20260810.md b/product-source/hololake-platform/architecture/HOLOLAKE-DOMAIN-MANIFEST-RUNTIME-POLICY-CONTRACT-20260810.md new file mode 100644 index 0000000..9e2a4d4 --- /dev/null +++ b/product-source/hololake-platform/architecture/HOLOLAKE-DOMAIN-MANIFEST-RUNTIME-POLICY-CONTRACT-20260810.md @@ -0,0 +1,28 @@ +# HoloLake 域 Manifest 运行策略契约 + +- 契约编号:`HLP-DOMAIN-MANIFEST-RUNTIME-POLICY-001` +- 阶段:`DEV-20260809-007 / Stage 007` +- 状态:实现验收基线 +- 日期:2026-08-10 + +## 目标 + +将“统一 GH-AIOS 公共灯塔 + 五个公开门厅 + 登录后进入五套独立域运行体”的域边界落到可验签的运行契约。路由、主题所有权、主题包和会话权限不能由页面临时状态决定,必须由受信签名者发布的域 Manifest 一并签名。 + +## 验收标准 + +1. 域 Manifest 的签名内容必须绑定运行路由、主题所有权、主题包、权限策略引用、允许的会话 scope 和禁止的数据 scope;修改任一字段均使摘要或签名校验失败。 +2. 第五域 · 光湖本源域只能声明 `fifth-domain` 主题所有权,并使用 `theme://origin-domain/` 命名空间;前四域只能声明 `domain-team`,且不能复用第五域主题命名空间。 +3. 每个 Manifest 的允许 scope 必须包含 `domain:enter`,会话能力中的全部 scope 必须是该签名允许列表的子集。 +4. 禁止的数据 scope 必须显式、非空、无重复;私人关系核心必须能作为禁止项由域策略隔离,不能因登录而默认开放。 +5. 未知字段、空值、重复 scope、越权 scope、所有权错配、非法引用或任何签名内容篡改,均必须在加载域运行体之前 fail closed。 +6. 验证后返回规范化且冻结的运行策略,不继续信任调用方传入的可变对象。 +7. 本阶段代码完成、测试通过、桌面部署健康,只证明客户端具备契约守卫;在生产 Manifest、登记端点、交接端点和域会话不存在时,不得声称真实域运行体已经上线。 + +## 固定边界 + +- GH-AIOS 公共灯塔与通用外壳保持统一设计语法。 +- 前四域的主题由各自主控团队发布并签名;HoloLake 不代替其定义视觉内容。 +- 第五域主题由第五域持有,采用已选定的“湖面映星”方向。 +- 公共门厅信息不等于登录后的域资源,Manifest 验签也不等于节点身份、私钥持有证明或在线连接回执。 +