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 21d35cd..90afb47 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 @@ -3,16 +3,21 @@ import assert from 'node:assert/strict'; import { createHash, generateKeyPairSync, sign } from 'node:crypto'; import { domainManifestSigningBytes } from './domain-access.js'; import { DomainAccessOrchestrator } from './domain-access-orchestrator.js'; +import { nodeRegistrationClaimSigningBytes } from './node-registration.js'; +import { NodeRegistrationSnapshotLoader } from './node-registration-snapshot.js'; import { TrustedSignerSnapshotLoader } from './trusted-signer-snapshot.js'; const NOW = 1_786_291_200_000; const DOMAIN_ID = 'DOM-FIFTH-0001'; const NODE_ID = 'LOCAL-001'; const NODE_TYPE = 'local-terminal' as const; +const ACCOUNT_ID = 'bingshuo'; const COMMIT = 'a'.repeat(40); const ANCHOR_URL = 'https://guanghulab.com/api/ai/v1/anchor'; -const REGISTRY_URL = `https://guanghulab.com/code/bingshuo/guanghu-ice-heart/raw/commit/${COMMIT}/routing/trusted-domain-manifest-signers.json`; -const { privateKey, publicKey } = generateKeyPairSync('ed25519'); +const SIGNER_REGISTRY_URL = `https://guanghulab.com/code/bingshuo/guanghu-ice-heart/raw/commit/${COMMIT}/routing/trusted-domain-manifest-signers.json`; +const NODE_REGISTRY_URL = `https://guanghulab.com/code/bingshuo/guanghu-ice-heart/raw/commit/${COMMIT}/routing/node-registration-endpoints.json`; +const manifestKeys = generateKeyPairSync('ed25519'); +const registrationKeys = generateKeyPairSync('ed25519'); function anchor() { return { @@ -29,6 +34,11 @@ function anchor() { id: 'GH-AIOS-TRUSTED-DOMAIN-MANIFEST-SIGNERS-001', version: '1.0.0', }, + node_registration_endpoints: { + path: 'routing/node-registration-endpoints.json', + id: 'GH-AIOS-NODE-REGISTRATION-ENDPOINTS-001', + version: '1.0.0', + }, }, navigation_source: { anchor_id: 'GLW-PUBLIC-NAV-ANCHOR-001', @@ -39,7 +49,7 @@ function anchor() { }; } -function registry(signers: unknown[] = []) { +function signerRegistry(signers: unknown[] = []) { return { registryId: 'GH-AIOS-TRUSTED-DOMAIN-MANIFEST-SIGNERS-001', schema: 'gh-aios.trusted-domain-manifest-signers/v1', @@ -53,23 +63,72 @@ function activeSigner() { return { algorithm: 'Ed25519', domainIds: [DOMAIN_ID], - publicKeyPem: publicKey.export({ format: 'pem', type: 'spki' }).toString(), + publicKeyPem: manifestKeys.publicKey.export({ format: 'pem', type: 'spki' }).toString(), repositoryId: 'REPO-014', signerId: 'GH-LIGHTHOUSE-001', status: 'ACTIVE', }; } -function loader(readRegistry: () => unknown = () => registry()) { +function nodeRegistry(version = '1.0.0') { + return { + endpoints: [{ + algorithm: 'Ed25519', + domainIds: [DOMAIN_ID], + endpointId: 'GH-NODE-REG-001', + nodeTypes: [NODE_TYPE], + publicKeyPem: registrationKeys.publicKey.export({ format: 'pem', type: 'spki' }).toString(), + signerId: 'GH-NODE-REG-SIGNER-001', + status: 'ACTIVE', + url: 'https://guanghulab.com/api/ai/v1/node-registrations/claims', + }], + registryId: 'GH-AIOS-NODE-REGISTRATION-ENDPOINTS-001', + schema: 'gh-aios.node-registration-endpoints/v1', + state: 'CURRENT', + version, + }; +} + +function signerLoader(readRegistry: () => unknown = () => signerRegistry()) { return new TrustedSignerSnapshotLoader({ async fetchJson(url) { if (url === ANCHOR_URL) return anchor(); - if (url === REGISTRY_URL) return readRegistry(); + if (url === SIGNER_REGISTRY_URL) return readRegistry(); throw new Error('unexpected_url'); }, }); } +function registrationLoader(readRegistry: () => unknown = () => nodeRegistry()) { + return new NodeRegistrationSnapshotLoader({ + async fetchJson(url) { + if (url === ANCHOR_URL) return anchor(); + if (url === NODE_REGISTRY_URL) return readRegistry(); + throw new Error('unexpected_url'); + }, + }); +} + +function validRegistrationClaim() { + const payload = { + accountId: ACCOUNT_ID, + claimId: 'NODE-CLAIM-001', + domainId: DOMAIN_ID, + endpointId: 'GH-NODE-REG-001', + expiresAt: NOW + 60_000, + issuedAt: NOW - 1_000, + nodeId: NODE_ID, + nodeKeyFingerprint: 'c'.repeat(64), + nodeType: NODE_TYPE, + schema: 'gh-aios.node-registration-claim/v1' as const, + signerId: 'GH-NODE-REG-SIGNER-001', + }; + return { + ...payload, + signature: sign(null, nodeRegistrationClaimSigningBytes(payload), registrationKeys.privateKey).toString('base64'), + }; +} + function validHandoff() { const payload = { domainId: DOMAIN_ID, @@ -93,7 +152,7 @@ function validHandoff() { manifest: { digest, ...payload, - signature: sign(null, bytes, privateKey).toString('base64'), + signature: sign(null, bytes, manifestKeys.privateKey).toString('base64'), }, sessionCapability: { capabilityId: 'CAP-001', @@ -106,32 +165,37 @@ function validHandoff() { }; } -test('projects public trust health without asking for a handoff before login', async () => { +const loggedOutIdentity = { async read() { return { accountId: '', accountVerified: false, nodeId: '' }; } }; +const loggedInIdentity = { async read() { return { accountId: ACCOUNT_ID, accountVerified: true, nodeId: NODE_ID }; } }; +const validRegistrationSource = { async read() { return validRegistrationClaim(); } }; + +test('projects both trust-source states without asking for claims or handoff before login', async () => { + let claimReads = 0; let handoffReads = 0; const orchestrator = new DomainAccessOrchestrator( - loader(), - { async read() { return { accountVerified: false, nodeId: '', nodeRegistrationVerified: false }; } }, + signerLoader(), + registrationLoader(), + loggedOutIdentity, + { async read() { claimReads += 1; return null; } }, { async read() { handoffReads += 1; return null; } }, ); const status = await orchestrator.domainAccess(DOMAIN_ID, NODE_TYPE, NOW); + assert.equal(claimReads, 0); assert.equal(handoffReads, 0); assert.equal(status.stage, 'login-required'); assert.equal(status.runtimeReady, false); - assert.equal(status.localWorkspaceAllowed, true); - assert.deepEqual(status.trustSource, { - status: 'CURRENT', - sourceCommit: COMMIT, - registryVersion: '1.0.0', - signerCount: 0, - }); + assert.equal(status.nodeRegistrationSource.status, 'CURRENT'); + assert.equal(status.trustSource.status, 'CURRENT'); }); -test('an authenticated node without a verified handoff remains identity-only', async () => { +test('a signed node registration without a handoff remains identity-only', async () => { const orchestrator = new DomainAccessOrchestrator( - loader(), - { async read() { return { accountVerified: true, nodeId: NODE_ID, nodeRegistrationVerified: true }; } }, + signerLoader(), + registrationLoader(), + loggedInIdentity, + validRegistrationSource, { async read() { return null; } }, ); @@ -139,14 +203,17 @@ test('an authenticated node without a verified handoff remains identity-only', a assert.equal(status.stage, 'identity-verified'); assert.equal(status.runtimeReady, false); + assert.equal(status.blockers.includes('verified_node_registration_missing'), false); assert.ok(status.blockers.includes('verified_domain_manifest_missing')); }); -test('a code-channel account without node registration never contacts a domain handoff route', async () => { +test('a code-channel account cannot supply registration and never contacts a handoff route', async () => { let handoffReads = 0; const orchestrator = new DomainAccessOrchestrator( - loader(), - { async read() { return { accountVerified: true, nodeId: NODE_ID, nodeRegistrationVerified: false }; } }, + signerLoader(), + registrationLoader(), + loggedInIdentity, + { async read() { return null; } }, { async read() { handoffReads += 1; return validHandoff() as never; } }, ); @@ -157,10 +224,12 @@ test('a code-channel account without node registration never contacts a domain h assert.ok(status.blockers.includes('verified_node_registration_missing')); }); -test('a loaded signer snapshot and verified handoff project runtime-ready', async () => { +test('current registries, signed node claim and verified handoff project runtime-ready', async () => { const orchestrator = new DomainAccessOrchestrator( - loader(() => registry([activeSigner()])), - { async read() { return { accountVerified: true, nodeId: NODE_ID, nodeRegistrationVerified: true }; } }, + signerLoader(() => signerRegistry([activeSigner()])), + registrationLoader(), + loggedInIdentity, + validRegistrationSource, { async read() { return { handoff: validHandoff(), @@ -174,33 +243,38 @@ test('a loaded signer snapshot and verified handoff project runtime-ready', asyn assert.equal(status.stage, 'runtime-ready'); assert.equal(status.runtimeReady, true); assert.deepEqual(status.blockers, []); - assert.equal(status.trustSource.status, 'CURRENT'); }); -test('source and handoff failures are reduced to safe status without raw details', async () => { +test('source failures are reduced to safe status without raw details', async () => { + const unavailableSigner = new TrustedSignerSnapshotLoader({ async fetchJson() { throw new Error('secret signer detail'); } }); + const unavailableRegistration = new NodeRegistrationSnapshotLoader({ async fetchJson() { throw new Error('secret registration detail'); } }); const orchestrator = new DomainAccessOrchestrator( - new TrustedSignerSnapshotLoader({ async fetchJson() { throw new Error('secret source detail'); } }), - { async read() { return { accountVerified: true, nodeId: NODE_ID, nodeRegistrationVerified: true }; } }, + unavailableSigner, + unavailableRegistration, + loggedInIdentity, + { async read() { throw new Error('secret claim detail'); } }, { async read() { throw new Error('secret handoff detail'); } }, ); const status = await orchestrator.domainAccess(DOMAIN_ID, NODE_TYPE, NOW); - assert.equal(status.stage, 'identity-verified'); + assert.equal(status.stage, 'login-required'); assert.equal(status.runtimeReady, false); + assert.equal(status.nodeRegistrationSource.status, 'UNAVAILABLE'); assert.equal(status.trustSource.status, 'UNAVAILABLE'); - assert.equal(status.trustSource.reason, 'SOURCE_UNAVAILABLE'); assert.equal(JSON.stringify(status).includes('secret'), false); }); -test('a last-known-good signer can project access while preserving degraded health', async () => { - let currentRegistry: unknown = registry([activeSigner()]); - const signerLoader = loader(() => currentRegistry); - await signerLoader.refresh(); - currentRegistry = { ...registry([activeSigner()]), version: 'invalid' }; +test('a degraded signer may preserve handoff verification while node registration must be current', async () => { + let currentSignerRegistry: unknown = signerRegistry([activeSigner()]); + const currentSignerLoader = signerLoader(() => currentSignerRegistry); + await currentSignerLoader.refresh(); + currentSignerRegistry = { ...signerRegistry([activeSigner()]), version: 'invalid' }; const orchestrator = new DomainAccessOrchestrator( - signerLoader, - { async read() { return { accountVerified: true, nodeId: NODE_ID, nodeRegistrationVerified: true }; } }, + currentSignerLoader, + registrationLoader(), + loggedInIdentity, + validRegistrationSource, { async read() { return { handoff: validHandoff(), @@ -210,8 +284,30 @@ test('a last-known-good signer can project access while preserving degraded heal ); const status = await orchestrator.domainAccess(DOMAIN_ID, NODE_TYPE, NOW); - assert.equal(status.runtimeReady, true); assert.equal(status.trustSource.status, 'DEGRADED_LAST_KNOWN_GOOD'); - assert.equal(status.trustSource.reason, 'REGISTRY_INVALID'); + assert.equal(status.nodeRegistrationSource.status, 'CURRENT'); +}); + +test('a degraded node-registration registry cannot authorize a claim', async () => { + let currentNodeRegistry: unknown = nodeRegistry(); + const currentNodeLoader = registrationLoader(() => currentNodeRegistry); + await currentNodeLoader.refresh(); + currentNodeRegistry = nodeRegistry('2.0.0'); + let claimReads = 0; + let handoffReads = 0; + const orchestrator = new DomainAccessOrchestrator( + signerLoader(() => signerRegistry([activeSigner()])), + currentNodeLoader, + loggedInIdentity, + { async read() { claimReads += 1; return validRegistrationClaim(); } }, + { async read() { handoffReads += 1; return null; } }, + ); + + const status = await orchestrator.domainAccess(DOMAIN_ID, NODE_TYPE, NOW); + assert.equal(status.nodeRegistrationSource.status, 'DEGRADED_LAST_KNOWN_GOOD'); + assert.equal(status.runtimeReady, false); + assert.ok(status.blockers.includes('verified_node_registration_missing')); + assert.equal(claimReads, 0); + assert.equal(handoffReads, 0); }); diff --git a/product-source/guanghu-knowledge-base/server/domain-access-orchestrator.ts b/product-source/guanghu-knowledge-base/server/domain-access-orchestrator.ts index 9f45d9b..323ab56 100644 --- a/product-source/guanghu-knowledge-base/server/domain-access-orchestrator.ts +++ b/product-source/guanghu-knowledge-base/server/domain-access-orchestrator.ts @@ -9,19 +9,41 @@ import { TrustedSignerSnapshotLoader, type TrustedSignerSnapshotReceipt, } from './trusted-signer-snapshot.js'; +import { + resolveNodeRegistrationEndpoint, + verifyNodeRegistrationClaim, +} from './node-registration.js'; +import { + assertLoadedNodeRegistrationSnapshot, + NodeRegistrationSnapshotLoader, + type NodeRegistrationSnapshotReceipt, +} from './node-registration-snapshot.js'; const IDENTIFIER_PATTERN = /^[A-Z0-9][A-Z0-9._:-]{1,159}$/; export interface DomainIdentityProjection { + accountId: string; accountVerified: boolean; nodeId: string; - nodeRegistrationVerified: boolean; } export interface DomainIdentitySource { read(domainId: string, nodeType: DomainNodeType): Promise; } +export interface NodeRegistrationClaimRequest { + accountId: string; + domainId: string; + endpointId: string; + endpointUrl: string; + nodeId: string; + nodeType: DomainNodeType; +} + +export interface NodeRegistrationClaimSource { + read(request: Readonly): Promise; +} + export interface DomainRuntimeHandoffCandidate { handoff: unknown; signerLookup: { @@ -40,6 +62,7 @@ export interface DomainAccessProjection { localWorkspaceAllowed: true; nodeId: string; nodeType: DomainNodeType; + nodeRegistrationSource: Readonly; runtimeReady: boolean; stage: DomainAccessStatus['stage']; trustSource: Readonly; @@ -47,42 +70,77 @@ export interface DomainAccessProjection { function safeIdentity(input: unknown): DomainIdentityProjection { if (typeof input !== 'object' || input === null || Array.isArray(input)) { - return { accountVerified: false, nodeId: '', nodeRegistrationVerified: false }; + return { accountId: '', accountVerified: false, nodeId: '' }; } const candidate = input as Record; + const accountId = typeof candidate.accountId === 'string' + && /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/.test(candidate.accountId) + ? candidate.accountId + : ''; const nodeId = typeof candidate.nodeId === 'string' && IDENTIFIER_PATTERN.test(candidate.nodeId) ? candidate.nodeId : ''; return { - accountVerified: candidate.accountVerified === true && Boolean(nodeId), + accountId, + accountVerified: candidate.accountVerified === true && Boolean(accountId) && Boolean(nodeId), nodeId, - nodeRegistrationVerified: candidate.nodeRegistrationVerified === true && Boolean(nodeId), }; } export class DomainAccessOrchestrator { constructor( private readonly signerSnapshots: TrustedSignerSnapshotLoader, + private readonly nodeRegistrationSnapshots: NodeRegistrationSnapshotLoader, private readonly identities: DomainIdentitySource, + private readonly nodeRegistrations: NodeRegistrationClaimSource, private readonly handoffs: DomainRuntimeHandoffSource, ) {} async domainAccess(domainId: string, nodeType: DomainNodeType, now = Date.now()): Promise> { if (!IDENTIFIER_PATTERN.test(domainId)) throw new Error('domain_access_domain_invalid'); if (nodeType !== 'local-terminal' && nodeType !== 'cloud-resident') throw new Error('domain_access_node_type_invalid'); - const [snapshot, identity] = await Promise.all([ + const [snapshot, nodeRegistrationSnapshot, identity] = await Promise.all([ this.signerSnapshots.refresh(), + this.nodeRegistrationSnapshots.refresh(), this.identities.read(domainId, nodeType).then(safeIdentity, () => safeIdentity(null)), ]); + let nodeRegistrationVerified = false; + if (identity.accountVerified && nodeRegistrationSnapshot.receipt.status === 'CURRENT') { + try { + const loaded = assertLoadedNodeRegistrationSnapshot(nodeRegistrationSnapshot); + const endpoint = loaded.registry && resolveNodeRegistrationEndpoint(loaded.registry, { domainId, nodeType }); + if (endpoint) { + const claim = await this.nodeRegistrations.read(Object.freeze({ + accountId: identity.accountId, + domainId, + endpointId: endpoint.endpointId, + endpointUrl: endpoint.url, + nodeId: identity.nodeId, + nodeType, + })); + if (claim) { + verifyNodeRegistrationClaim(claim, { + accountId: identity.accountId, + domainId, + nodeId: identity.nodeId, + nodeType, + }, endpoint, now); + nodeRegistrationVerified = true; + } + } + } catch { + nodeRegistrationVerified = false; + } + } const evidence: DomainAccessEvidence = { accountVerified: identity.accountVerified, domainId, nodeId: identity.nodeId, - nodeRegistrationVerified: identity.nodeRegistrationVerified, + nodeRegistrationVerified, nodeType, }; - if (identity.accountVerified && identity.nodeRegistrationVerified) { + if (identity.accountVerified && nodeRegistrationVerified) { let candidate: DomainRuntimeHandoffCandidate | null = null; try { candidate = await this.handoffs.read(domainId, identity.nodeId, nodeType); @@ -111,6 +169,7 @@ export class DomainAccessOrchestrator { return Object.freeze({ ...status, blockers: Object.freeze([...status.blockers]), + nodeRegistrationSource: nodeRegistrationSnapshot.receipt, trustSource: snapshot.receipt, }); } diff --git a/product-source/guanghu-knowledge-base/server/node-registration-snapshot.test.ts b/product-source/guanghu-knowledge-base/server/node-registration-snapshot.test.ts new file mode 100644 index 0000000..1760138 --- /dev/null +++ b/product-source/guanghu-knowledge-base/server/node-registration-snapshot.test.ts @@ -0,0 +1,111 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { generateKeyPairSync } from 'node:crypto'; +import { + assertLoadedNodeRegistrationSnapshot, + NodeRegistrationSnapshotLoader, +} from './node-registration-snapshot.js'; + +const COMMIT = 'a'.repeat(40); +const ANCHOR_URL = 'https://guanghulab.com/api/ai/v1/anchor'; +const REGISTRY_URL = `https://guanghulab.com/code/bingshuo/guanghu-ice-heart/raw/commit/${COMMIT}/routing/node-registration-endpoints.json`; +const { publicKey } = generateKeyPairSync('ed25519'); + +function anchor(includeMap = true) { + return { + schema: 'guanghu.public-navigation-anchor/v1', + anchor_id: 'GLW-PUBLIC-NAV-ANCHOR-001', + state: 'CURRENT_CANONICAL', + repository_id: 'REPO-012', + branch: 'main', + public_entry: ANCHOR_URL, + code_entry: 'https://guanghulab.com/code/bingshuo/guanghu-ice-heart', + maps: includeMap ? { + node_registration_endpoints: { + path: 'routing/node-registration-endpoints.json', + id: 'GH-AIOS-NODE-REGISTRATION-ENDPOINTS-001', + version: '1.0.0', + }, + } : {}, + navigation_source: { + anchor_id: 'GLW-PUBLIC-NAV-ANCHOR-001', + source_commit: COMMIT, + source_mode: 'REPO-012_MAIN_GIT_SNAPSHOT', + source_degraded: false, + }, + }; +} + +function registry(version = '1.0.0') { + return { + endpoints: [{ + algorithm: 'Ed25519', + domainIds: ['DOM-FIFTH-0001'], + endpointId: 'GH-NODE-REG-001', + nodeTypes: ['local-terminal'], + publicKeyPem: publicKey.export({ format: 'pem', type: 'spki' }).toString(), + signerId: 'GH-NODE-REG-SIGNER-001', + status: 'ACTIVE', + url: 'https://guanghulab.com/api/ai/v1/node-registrations/claims', + }], + registryId: 'GH-AIOS-NODE-REGISTRATION-ENDPOINTS-001', + schema: 'gh-aios.node-registration-endpoints/v1', + state: 'CURRENT', + version, + }; +} + +function loader(readAnchor: () => unknown, readRegistry: () => unknown) { + return new NodeRegistrationSnapshotLoader({ + async fetchJson(url) { + if (url === ANCHOR_URL) return readAnchor(); + if (url === REGISTRY_URL) return readRegistry(); + throw new Error('unexpected_url'); + }, + }); +} + +test('loads one exact registry from the anchor source commit', async () => { + const snapshot = await loader(() => anchor(), () => registry()).refresh(); + assert.equal(snapshot.receipt.status, 'CURRENT'); + assert.equal(snapshot.receipt.sourceCommit, COMMIT); + assert.equal(snapshot.receipt.endpointCount, 1); + assert.equal(assertLoadedNodeRegistrationSnapshot(snapshot), snapshot); +}); + +test('the current public anchor without a registration map is explicitly unavailable', async () => { + const snapshot = await loader(() => anchor(false), () => registry()).refresh(); + assert.deepEqual(snapshot.receipt, { + endpointCount: 0, + reason: 'REGISTRY_UNREGISTERED', + registryVersion: null, + sourceCommit: null, + status: 'UNAVAILABLE', + }); + assert.equal(snapshot.registry, null); +}); + +test('source and registry mismatches fail closed', async () => { + const unavailable = await loader(() => { throw new Error('offline'); }, () => registry()).refresh(); + assert.equal(unavailable.receipt.reason, 'SOURCE_UNAVAILABLE'); + const invalid = await loader(() => anchor(), () => registry('2.0.0')).refresh(); + assert.equal(invalid.receipt.reason, 'REGISTRY_INVALID'); +}); + +test('last-known-good state is reported degraded and cannot be mistaken for current', async () => { + let currentAnchor: unknown = anchor(); + const snapshotLoader = loader(() => currentAnchor, () => registry()); + await snapshotLoader.refresh(); + currentAnchor = anchor(false); + const degraded = await snapshotLoader.refresh(); + assert.equal(degraded.receipt.status, 'DEGRADED_LAST_KNOWN_GOOD'); + assert.equal(degraded.receipt.reason, 'REGISTRY_UNREGISTERED'); + assert.equal(degraded.receipt.sourceCommit, COMMIT); +}); + +test('caller-created snapshots are rejected', () => { + assert.throws(() => assertLoadedNodeRegistrationSnapshot({ + receipt: { endpointCount: 0, registryVersion: null, sourceCommit: null, status: 'UNAVAILABLE' }, + registry: null, + }), /snapshot_unregistered/); +}); diff --git a/product-source/guanghu-knowledge-base/server/node-registration-snapshot.ts b/product-source/guanghu-knowledge-base/server/node-registration-snapshot.ts new file mode 100644 index 0000000..c00f34d --- /dev/null +++ b/product-source/guanghu-knowledge-base/server/node-registration-snapshot.ts @@ -0,0 +1,169 @@ +import { + parseNodeRegistrationEndpointRegistry, + type NodeRegistrationEndpointRegistry, +} from './node-registration.js'; + +const ANCHOR_URL = 'https://guanghulab.com/api/ai/v1/anchor'; +const CODE_URL = 'https://guanghulab.com/code/bingshuo/guanghu-ice-heart'; +const REGISTRY_ID = 'GH-AIOS-NODE-REGISTRATION-ENDPOINTS-001'; +const REGISTRY_PATH = 'routing/node-registration-endpoints.json'; +const COMMIT_PATTERN = /^[a-f0-9]{40}(?:[a-f0-9]{24})?$/; +const VERSION_PATTERN = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/; +const registeredSnapshots = new WeakSet(); + +export interface NodeRegistrationSnapshotTransport { + fetchJson(url: string): Promise; +} + +export type NodeRegistrationSnapshotReason = + | 'ANCHOR_INVALID' + | 'REGISTRY_INVALID' + | 'REGISTRY_UNREGISTERED' + | 'SOURCE_UNAVAILABLE'; + +export interface NodeRegistrationSnapshotReceipt { + endpointCount: number; + reason?: NodeRegistrationSnapshotReason; + registryVersion: string | null; + sourceCommit: string | null; + status: 'CURRENT' | 'DEGRADED_LAST_KNOWN_GOOD' | 'UNAVAILABLE'; +} + +export interface NodeRegistrationSnapshotResult { + receipt: Readonly; + registry: NodeRegistrationEndpointRegistry | null; +} + +interface ParsedAnchor { + registryVersion: string; + sourceCommit: string; +} + +class SnapshotLoadError extends Error { + constructor(readonly reason: NodeRegistrationSnapshotReason) { + super(reason); + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function parseAnchor(input: unknown): ParsedAnchor { + if (!isRecord(input) + || input.schema !== 'guanghu.public-navigation-anchor/v1' + || input.anchor_id !== 'GLW-PUBLIC-NAV-ANCHOR-001' + || input.state !== 'CURRENT_CANONICAL' + || input.repository_id !== 'REPO-012' + || input.branch !== 'main' + || input.public_entry !== ANCHOR_URL + || input.code_entry !== CODE_URL + || !isRecord(input.maps) + || !isRecord(input.navigation_source) + || input.navigation_source.anchor_id !== 'GLW-PUBLIC-NAV-ANCHOR-001' + || input.navigation_source.source_mode !== 'REPO-012_MAIN_GIT_SNAPSHOT' + || input.navigation_source.source_degraded !== false + || typeof input.navigation_source.source_commit !== 'string' + || !COMMIT_PATTERN.test(input.navigation_source.source_commit)) { + throw new SnapshotLoadError('ANCHOR_INVALID'); + } + if (!isRecord(input.maps.node_registration_endpoints)) { + throw new SnapshotLoadError('REGISTRY_UNREGISTERED'); + } + const map = input.maps.node_registration_endpoints; + if (map.path !== REGISTRY_PATH + || map.id !== REGISTRY_ID + || typeof map.version !== 'string' + || !VERSION_PATTERN.test(map.version)) { + throw new SnapshotLoadError('ANCHOR_INVALID'); + } + return Object.freeze({ + registryVersion: map.version, + sourceCommit: input.navigation_source.source_commit, + }); +} + +function registryUrl(sourceCommit: string): string { + return `${CODE_URL}/raw/commit/${sourceCommit}/${REGISTRY_PATH}`; +} + +function receipt( + status: NodeRegistrationSnapshotReceipt['status'], + registry: NodeRegistrationEndpointRegistry | null, + reason?: NodeRegistrationSnapshotReason, +): Readonly { + return Object.freeze({ + endpointCount: registry?.endpoints.length ?? 0, + ...(reason ? { reason } : {}), + registryVersion: registry?.version ?? null, + sourceCommit: registry?.source.sourceCommit ?? null, + status, + }); +} + +function result( + registry: NodeRegistrationEndpointRegistry | null, + snapshotReceipt: Readonly, +): NodeRegistrationSnapshotResult { + const loaded = Object.freeze({ receipt: snapshotReceipt, registry }); + registeredSnapshots.add(loaded); + return loaded; +} + +export function assertLoadedNodeRegistrationSnapshot( + snapshot: NodeRegistrationSnapshotResult, +): NodeRegistrationSnapshotResult { + if (!registeredSnapshots.has(snapshot)) throw new Error('node_registration_snapshot_unregistered'); + return snapshot; +} + +export class NodeRegistrationSnapshotLoader { + #inFlight: Promise | null = null; + #lastKnownGood: NodeRegistrationEndpointRegistry | null = null; + + constructor(private readonly transport: NodeRegistrationSnapshotTransport) {} + + refresh(): Promise { + if (this.#inFlight) return this.#inFlight; + this.#inFlight = this.#load().finally(() => { + this.#inFlight = null; + }); + return this.#inFlight; + } + + async #load(): Promise { + try { + let rawAnchor: unknown; + try { + rawAnchor = await this.transport.fetchJson(ANCHOR_URL); + } catch { + throw new SnapshotLoadError('SOURCE_UNAVAILABLE'); + } + const anchor = parseAnchor(rawAnchor); + let rawRegistry: unknown; + try { + rawRegistry = await this.transport.fetchJson(registryUrl(anchor.sourceCommit)); + } catch { + throw new SnapshotLoadError('SOURCE_UNAVAILABLE'); + } + let registry: NodeRegistrationEndpointRegistry; + try { + registry = parseNodeRegistrationEndpointRegistry(rawRegistry, { + repositoryId: 'REPO-012', + sourceCommit: anchor.sourceCommit, + sourceUrl: registryUrl(anchor.sourceCommit), + }); + } catch { + throw new SnapshotLoadError('REGISTRY_INVALID'); + } + if (registry.version !== anchor.registryVersion) throw new SnapshotLoadError('REGISTRY_INVALID'); + this.#lastKnownGood = registry; + return result(registry, receipt('CURRENT', registry)); + } catch (error) { + const reason = error instanceof SnapshotLoadError ? error.reason : 'SOURCE_UNAVAILABLE'; + return this.#lastKnownGood + ? result(this.#lastKnownGood, receipt('DEGRADED_LAST_KNOWN_GOOD', this.#lastKnownGood, reason)) + : result(null, receipt('UNAVAILABLE', null, reason)); + } + } +} diff --git a/product-source/guanghu-knowledge-base/server/node-registration.test.ts b/product-source/guanghu-knowledge-base/server/node-registration.test.ts new file mode 100644 index 0000000..4e7dcad --- /dev/null +++ b/product-source/guanghu-knowledge-base/server/node-registration.test.ts @@ -0,0 +1,136 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { generateKeyPairSync, sign } from 'node:crypto'; +import { + nodeRegistrationClaimSigningBytes, + parseNodeRegistrationEndpointRegistry, + resolveNodeRegistrationEndpoint, + verifyNodeRegistrationClaim, +} from './node-registration.js'; + +const NOW = 1_786_291_200_000; +const COMMIT = 'a'.repeat(40); +const { privateKey, publicKey } = generateKeyPairSync('ed25519'); +const source = { + repositoryId: 'REPO-012' as const, + sourceCommit: COMMIT, + sourceUrl: `https://guanghulab.com/code/bingshuo/guanghu-ice-heart/raw/commit/${COMMIT}/routing/node-registration-endpoints.json`, +}; + +function endpoint(overrides: Record = {}) { + return { + algorithm: 'Ed25519', + domainIds: ['DOM-FIFTH-0001'], + endpointId: 'GH-NODE-REG-001', + nodeTypes: ['local-terminal', 'cloud-resident'], + publicKeyPem: publicKey.export({ format: 'pem', type: 'spki' }).toString(), + signerId: 'GH-NODE-REG-SIGNER-001', + status: 'ACTIVE', + url: 'https://guanghulab.com/api/ai/v1/node-registrations/claims', + ...overrides, + }; +} + +function registry(endpoints: unknown[] = [endpoint()]) { + return parseNodeRegistrationEndpointRegistry({ + endpoints, + registryId: 'GH-AIOS-NODE-REGISTRATION-ENDPOINTS-001', + schema: 'gh-aios.node-registration-endpoints/v1', + state: 'CURRENT', + version: '1.0.0', + }, source); +} + +function signedClaim(overrides: Record = {}) { + const payload = { + accountId: 'bingshuo', + claimId: 'NODE-CLAIM-001', + domainId: 'DOM-FIFTH-0001', + endpointId: 'GH-NODE-REG-001', + expiresAt: NOW + 60_000, + issuedAt: NOW - 1_000, + nodeId: 'JD-FD-PRIMARY', + nodeKeyFingerprint: 'b'.repeat(64), + nodeType: 'local-terminal' as const, + schema: 'gh-aios.node-registration-claim/v1' as const, + signerId: 'GH-NODE-REG-SIGNER-001', + ...overrides, + }; + return { + ...payload, + signature: sign(null, nodeRegistrationClaimSigningBytes(payload as never), privateKey).toString('base64'), + }; +} + +test('resolves one active endpoint for a domain and node type', () => { + const resolved = resolveNodeRegistrationEndpoint(registry(), { + domainId: 'DOM-FIFTH-0001', + nodeType: 'local-terminal', + }); + assert.equal(resolved?.endpointId, 'GH-NODE-REG-001'); +}); + +test('rejects credentials, arbitrary origins and ambiguous active scopes', () => { + assert.throws(() => registry([endpoint({ url: 'https://user:secret@guanghulab.com/api/ai/v1/node-registrations' })]), /registry_invalid/); + assert.throws(() => registry([endpoint({ url: 'https://example.com/api/ai/v1/node-registrations' })]), /registry_invalid/); + assert.throws(() => registry([ + endpoint(), + endpoint({ endpointId: 'GH-NODE-REG-002', signerId: 'GH-NODE-REG-SIGNER-002' }), + ]), /registry_invalid/); +}); + +test('a signed registration binds account, domain, node, type, key and endpoint', () => { + const resolved = resolveNodeRegistrationEndpoint(registry(), { + domainId: 'DOM-FIFTH-0001', + nodeType: 'local-terminal', + }); + assert.ok(resolved); + const verified = verifyNodeRegistrationClaim(signedClaim(), { + accountId: 'bingshuo', + domainId: 'DOM-FIFTH-0001', + nodeId: 'JD-FD-PRIMARY', + nodeType: 'local-terminal', + }, resolved, NOW); + assert.equal(verified.nodeKeyFingerprint, 'b'.repeat(64)); + assert.equal(verified.verifiedAt, NOW); +}); + +test('copied booleans, unknown fields, mismatches and expired claims fail closed', () => { + const resolved = resolveNodeRegistrationEndpoint(registry(), { + domainId: 'DOM-FIFTH-0001', + nodeType: 'local-terminal', + }); + assert.ok(resolved); + const expected = { + accountId: 'bingshuo', + domainId: 'DOM-FIFTH-0001', + nodeId: 'JD-FD-PRIMARY', + nodeType: 'local-terminal' as const, + }; + assert.throws(() => verifyNodeRegistrationClaim({ ...signedClaim(), verified: true }, expected, resolved, NOW), /claim_invalid/); + assert.throws(() => verifyNodeRegistrationClaim(signedClaim(), { ...expected, accountId: 'other' }, resolved, NOW), /claim_invalid/); + assert.throws(() => verifyNodeRegistrationClaim(signedClaim(), { ...expected, nodeType: 'cloud-resident' }, resolved, NOW), /claim_invalid/); + assert.throws(() => verifyNodeRegistrationClaim(signedClaim({ expiresAt: NOW }), expected, resolved, NOW), /claim_invalid/); +}); + +test('tampering or signing with another key fails closed', () => { + const resolved = resolveNodeRegistrationEndpoint(registry(), { + domainId: 'DOM-FIFTH-0001', + nodeType: 'local-terminal', + }); + assert.ok(resolved); + const expected = { + accountId: 'bingshuo', + domainId: 'DOM-FIFTH-0001', + nodeId: 'JD-FD-PRIMARY', + nodeType: 'local-terminal' as const, + }; + assert.throws(() => verifyNodeRegistrationClaim({ ...signedClaim(), nodeKeyFingerprint: 'c'.repeat(64) }, expected, resolved, NOW), /claim_invalid/); + const other = generateKeyPairSync('ed25519'); + const claim = signedClaim(); + const forged = { + ...claim, + signature: sign(null, nodeRegistrationClaimSigningBytes(claim), other.privateKey).toString('base64'), + }; + assert.throws(() => verifyNodeRegistrationClaim(forged, expected, resolved, NOW), /claim_invalid/); +}); diff --git a/product-source/guanghu-knowledge-base/server/node-registration.ts b/product-source/guanghu-knowledge-base/server/node-registration.ts new file mode 100644 index 0000000..97f5754 --- /dev/null +++ b/product-source/guanghu-knowledge-base/server/node-registration.ts @@ -0,0 +1,269 @@ +import { createPublicKey, verify as verifySignature } from 'node:crypto'; +import type { DomainNodeType } from './domain-access.js'; + +const REGISTRY_SCHEMA = 'gh-aios.node-registration-endpoints/v1' as const; +const CLAIM_SCHEMA = 'gh-aios.node-registration-claim/v1' as const; +const IDENTIFIER_PATTERN = /^[A-Z0-9][A-Z0-9._:-]{1,159}$/; +const ACCOUNT_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/; +const COMMIT_PATTERN = /^[a-f0-9]{40}(?:[a-f0-9]{24})?$/; +const VERSION_PATTERN = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/; +const SHA256_PATTERN = /^[a-f0-9]{64}$/; +const MAX_CLAIM_LIFETIME_MS = 24 * 60 * 60 * 1000; +const MAX_CLOCK_SKEW_MS = 5 * 60 * 1000; +const registeredEndpoints = new WeakSet(); + +export interface NodeRegistrationEndpoint { + algorithm: 'Ed25519'; + domainIds: readonly string[]; + endpointId: string; + nodeTypes: readonly DomainNodeType[]; + publicKeyPem: string; + signerId: string; + status: 'ACTIVE' | 'REVOKED'; + url: string; +} + +export interface NodeRegistrationEndpointRegistrySource { + repositoryId: 'REPO-012'; + sourceCommit: string; + sourceUrl: string; +} + +export interface NodeRegistrationEndpointRegistry { + endpoints: readonly NodeRegistrationEndpoint[]; + registryId: 'GH-AIOS-NODE-REGISTRATION-ENDPOINTS-001'; + schema: typeof REGISTRY_SCHEMA; + source: Readonly; + state: 'CURRENT'; + version: string; +} + +export interface NodeRegistrationClaimPayload { + accountId: string; + claimId: string; + domainId: string; + endpointId: string; + expiresAt: number; + issuedAt: number; + nodeId: string; + nodeKeyFingerprint: string; + nodeType: DomainNodeType; + schema: typeof CLAIM_SCHEMA; + signerId: string; +} + +export interface VerifiedNodeRegistration extends NodeRegistrationClaimPayload { + signature: string; + verifiedAt: number; +} + +export interface NodeRegistrationLookup { + domainId: string; + nodeType: DomainNodeType; +} + +function invalidRegistry(): never { + throw new Error('node_registration_endpoint_registry_invalid'); +} + +function invalidClaim(): never { + throw new Error('node_registration_claim_invalid'); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function hasExactKeys(record: Record, keys: string[]): boolean { + const expected = [...keys].sort(); + const actual = Object.keys(record).sort(); + return actual.length === expected.length && actual.every((key, index) => key === expected[index]); +} + +function isIdentifier(value: unknown): value is string { + return typeof value === 'string' && IDENTIFIER_PATTERN.test(value); +} + +function parsePublicKey(value: unknown): string { + if (typeof value !== 'string' + || value.length > 4096 + || value.includes('PRIVATE KEY') + || !value.startsWith('-----BEGIN PUBLIC KEY-----\n') + || !value.endsWith('-----END PUBLIC KEY-----\n')) invalidRegistry(); + try { + const key = createPublicKey({ format: 'pem', key: value }); + if (key.asymmetricKeyType !== 'ed25519') invalidRegistry(); + } catch { + invalidRegistry(); + } + return value; +} + +function parseEndpointUrl(value: unknown): string { + if (typeof value !== 'string') invalidRegistry(); + let url: URL; + try { + url = new URL(value); + } catch { + invalidRegistry(); + } + if (url.protocol !== 'https:' + || url.hostname !== 'guanghulab.com' + || url.port + || url.username + || url.password + || url.search + || url.hash + || !url.pathname.startsWith('/api/ai/v1/')) invalidRegistry(); + return url.toString(); +} + +function parseEndpoint(input: unknown): NodeRegistrationEndpoint { + if (!isRecord(input) + || !hasExactKeys(input, ['algorithm', 'domainIds', 'endpointId', 'nodeTypes', 'publicKeyPem', 'signerId', 'status', 'url']) + || input.algorithm !== 'Ed25519' + || !isIdentifier(input.endpointId) + || !isIdentifier(input.signerId) + || (input.status !== 'ACTIVE' && input.status !== 'REVOKED') + || !Array.isArray(input.domainIds) + || input.domainIds.length === 0 + || input.domainIds.some(domainId => !isIdentifier(domainId)) + || new Set(input.domainIds).size !== input.domainIds.length + || !Array.isArray(input.nodeTypes) + || input.nodeTypes.length === 0 + || input.nodeTypes.some(nodeType => nodeType !== 'local-terminal' && nodeType !== 'cloud-resident') + || new Set(input.nodeTypes).size !== input.nodeTypes.length) invalidRegistry(); + const endpoint = Object.freeze({ + algorithm: 'Ed25519' as const, + domainIds: Object.freeze([...input.domainIds] as string[]), + endpointId: input.endpointId, + nodeTypes: Object.freeze([...input.nodeTypes] as DomainNodeType[]), + publicKeyPem: parsePublicKey(input.publicKeyPem), + signerId: input.signerId, + status: input.status, + url: parseEndpointUrl(input.url), + }); + registeredEndpoints.add(endpoint); + return endpoint; +} + +function parseSource(source: NodeRegistrationEndpointRegistrySource): Readonly { + if (!isRecord(source) + || !hasExactKeys(source, ['repositoryId', 'sourceCommit', 'sourceUrl']) + || source.repositoryId !== 'REPO-012' + || typeof source.sourceCommit !== 'string' + || !COMMIT_PATTERN.test(source.sourceCommit)) invalidRegistry(); + const expected = `https://guanghulab.com/code/bingshuo/guanghu-ice-heart/raw/commit/${source.sourceCommit}/routing/node-registration-endpoints.json`; + if (source.sourceUrl !== expected) invalidRegistry(); + return Object.freeze({ ...source }); +} + +export function parseNodeRegistrationEndpointRegistry( + input: unknown, + source: NodeRegistrationEndpointRegistrySource, +): NodeRegistrationEndpointRegistry { + if (!isRecord(input) + || !hasExactKeys(input, ['endpoints', 'registryId', 'schema', 'state', 'version']) + || input.registryId !== 'GH-AIOS-NODE-REGISTRATION-ENDPOINTS-001' + || input.schema !== REGISTRY_SCHEMA + || input.state !== 'CURRENT' + || typeof input.version !== 'string' + || !VERSION_PATTERN.test(input.version) + || !Array.isArray(input.endpoints)) invalidRegistry(); + const endpoints = input.endpoints.map(parseEndpoint); + if (new Set(endpoints.map(endpoint => endpoint.endpointId)).size !== endpoints.length) invalidRegistry(); + const activeScopes = new Set(); + for (const endpoint of endpoints.filter(candidate => candidate.status === 'ACTIVE')) { + for (const domainId of endpoint.domainIds) { + for (const nodeType of endpoint.nodeTypes) { + const scope = `${domainId}\0${nodeType}`; + if (activeScopes.has(scope)) invalidRegistry(); + activeScopes.add(scope); + } + } + } + return Object.freeze({ + endpoints: Object.freeze(endpoints), + registryId: input.registryId, + schema: input.schema, + source: parseSource(source), + state: input.state, + version: input.version, + }); +} + +export function resolveNodeRegistrationEndpoint( + registry: NodeRegistrationEndpointRegistry, + lookup: NodeRegistrationLookup, +): NodeRegistrationEndpoint | null { + const endpoint = registry.endpoints.find(candidate => candidate.status === 'ACTIVE' + && candidate.domainIds.includes(lookup.domainId) + && candidate.nodeTypes.includes(lookup.nodeType)); + if (!endpoint) return null; + if (!registeredEndpoints.has(endpoint)) throw new Error('node_registration_endpoint_unregistered'); + return endpoint; +} + +export function nodeRegistrationClaimSigningBytes(payload: NodeRegistrationClaimPayload): Buffer { + return Buffer.from(JSON.stringify({ + accountId: payload.accountId, + claimId: payload.claimId, + domainId: payload.domainId, + endpointId: payload.endpointId, + expiresAt: payload.expiresAt, + issuedAt: payload.issuedAt, + nodeId: payload.nodeId, + nodeKeyFingerprint: payload.nodeKeyFingerprint, + nodeType: payload.nodeType, + schema: payload.schema, + signerId: payload.signerId, + }), 'utf8'); +} + +export function verifyNodeRegistrationClaim( + input: unknown, + expected: Readonly<{ accountId: string; domainId: string; nodeId: string; nodeType: DomainNodeType }>, + endpoint: NodeRegistrationEndpoint, + now = Date.now(), +): Readonly { + if (!registeredEndpoints.has(endpoint) || endpoint.status !== 'ACTIVE') invalidClaim(); + if (!isRecord(input) + || !hasExactKeys(input, ['accountId', 'claimId', 'domainId', 'endpointId', 'expiresAt', 'issuedAt', 'nodeId', 'nodeKeyFingerprint', 'nodeType', 'schema', 'signature', 'signerId'])) invalidClaim(); + if (input.schema !== CLAIM_SCHEMA + || typeof input.accountId !== 'string' + || !ACCOUNT_ID_PATTERN.test(input.accountId) + || input.accountId !== expected.accountId + || !isIdentifier(input.claimId) + || input.domainId !== expected.domainId + || input.nodeId !== expected.nodeId + || input.nodeType !== expected.nodeType + || input.endpointId !== endpoint.endpointId + || input.signerId !== endpoint.signerId + || !endpoint.domainIds.includes(expected.domainId) + || !endpoint.nodeTypes.includes(expected.nodeType) + || typeof input.nodeKeyFingerprint !== 'string' + || !SHA256_PATTERN.test(input.nodeKeyFingerprint) + || typeof input.issuedAt !== 'number' + || !Number.isSafeInteger(input.issuedAt) + || typeof input.expiresAt !== 'number' + || !Number.isSafeInteger(input.expiresAt) + || input.issuedAt > now + MAX_CLOCK_SKEW_MS + || input.expiresAt <= now + || input.expiresAt <= input.issuedAt + || input.expiresAt - input.issuedAt > MAX_CLAIM_LIFETIME_MS + || typeof input.signature !== 'string') invalidClaim(); + const payload = input as unknown as NodeRegistrationClaimPayload; + let signature: Buffer; + try { + signature = Buffer.from(input.signature, 'base64'); + if (signature.length !== 64 || signature.toString('base64') !== input.signature) invalidClaim(); + if (!verifySignature(null, nodeRegistrationClaimSigningBytes(payload), endpoint.publicKeyPem, signature)) invalidClaim(); + } catch { + invalidClaim(); + } + return Object.freeze({ + ...payload, + signature: input.signature, + verifiedAt: now, + }); +} diff --git a/product-source/hololake-desktop/electron/main.ts b/product-source/hololake-desktop/electron/main.ts index f88cfba..22216ce 100644 --- a/product-source/hololake-desktop/electron/main.ts +++ b/product-source/hololake-desktop/electron/main.ts @@ -20,6 +20,7 @@ import fs from 'fs'; import { importKnowledgeFolder } from './folder-import.js'; import { DomainAccessOrchestrator } from '../../guanghu-knowledge-base/server/domain-access-orchestrator.js'; import { TrustedSignerSnapshotLoader } from '../../guanghu-knowledge-base/server/trusted-signer-snapshot.js'; +import { NodeRegistrationSnapshotLoader } from '../../guanghu-knowledge-base/server/node-registration-snapshot.js'; // ─── 配置 ─── @@ -324,22 +325,45 @@ const trustedSignerSnapshots = new TrustedSignerSnapshotLoader({ }, }); +const nodeRegistrationSnapshots = new NodeRegistrationSnapshotLoader({ + async fetchJson(url: string): Promise { + const response = await fetch(url, { + headers: { Accept: 'application/json' }, + signal: AbortSignal.timeout(5000), + }); + if (!response.ok) throw new Error(`node_registration_source_http_${response.status}`); + return response.json(); + }, +}); + const domainAccessOrchestrator = new DomainAccessOrchestrator( trustedSignerSnapshots, + nodeRegistrationSnapshots, { async read() { const stored = applyStoredServerAuth(); - if (!stored) return { accountVerified: false, nodeId: '', nodeRegistrationVerified: false }; + if (!stored) return { accountId: '', accountVerified: false, nodeId: '' }; try { - const { response } = await forgejoRequest(stored.nodeId, '/api/v1/user', {}, { token: stored.token }); - // Forgejo proves only the account part. A dedicated node-registration - // verifier must independently supply nodeRegistrationVerified. - return { accountVerified: response.ok, nodeId: stored.nodeId, nodeRegistrationVerified: false }; + const { response, data } = await forgejoRequest(stored.nodeId, '/api/v1/user', {}, { token: stored.token }); + // Forgejo proves only the account part. It cannot submit a node-registration + // boolean; a separately anchored signed claim is required below. + return { + accountId: response.ok && typeof data?.login === 'string' ? data.login : '', + accountVerified: response.ok, + nodeId: stored.nodeId, + }; } catch { - return { accountVerified: false, nodeId: stored.nodeId, nodeRegistrationVerified: false }; + return { accountId: '', accountVerified: false, nodeId: stored.nodeId }; } }, }, + { + async read() { + // Current REPO-012 does not yet register a node-registration endpoint. + // Keep this transport absent until that public registry and service exist. + return null; + }, + }, { async read() { // 当前灯塔尚未发布签名运行体交接端点。保持关闭,不从账号身份推导运行体权限。 diff --git a/product-source/hololake-platform/architecture/HOLOLAKE-NODE-REGISTRATION-CONTRACT-20260810.md b/product-source/hololake-platform/architecture/HOLOLAKE-NODE-REGISTRATION-CONTRACT-20260810.md new file mode 100644 index 0000000..e14bd4b --- /dev/null +++ b/product-source/hololake-platform/architecture/HOLOLAKE-NODE-REGISTRATION-CONTRACT-20260810.md @@ -0,0 +1,46 @@ +# HoloLake 节点登记与验证来源契约 · 2026-08-10 + +> 阶段:`DEV-20260809-007 / NODE-REGISTRATION-CONTRACT-001` +> +> 上游:`HLP-CURRENT-ARCH-001` 第 5 节“节点与登录协议” +> +> 状态:`IMPLEMENTATION_STAGE_ACCEPTANCE_BASELINE` + +## 验收标准 + +1. 账号验证源只能返回账号身份与用户选择的节点编号,不能直接声明节点已经登记。 +2. HoloLake 只读取 REPO-012 当前公共锚点同一提交声明的 + `GH-AIOS-NODE-REGISTRATION-ENDPOINTS-001`;任意本地配置、账号响应或未登记 URL 都不能成为 + 节点登记权威。 +3. 节点登记声明使用 Ed25519 签名,并精确绑定账号、域、节点编号、节点类型、节点公钥 + SHA-256 指纹、登记端点、签发者、签发时间与过期时间。 +4. 注册表缺失、来源降级、端点撤销、端点范围不匹配、声明过期、未知字段、签名错误或任一 + 绑定字段不匹配时,`nodeRegistrationVerified` 必须保持 `false`,且不得请求域运行体交接。 +5. 当前 REPO-012 锚点尚未登记此端点注册表,因此当前生产判断必须是 + `NODE_REGISTRATION_NOT_EXISTS_0`;本阶段代码完成不得冒充生产登记服务、节点密钥证明、 + 域会话或在线运行已经存在。 + +## 注册表契约 + +注册表固定路径为 `routing/node-registration-endpoints.json`,由公共锚点的 +`maps.node_registration_endpoints` 指向。每个端点声明: + +- `endpointId`、`signerId`、`Ed25519 publicKeyPem`; +- HTTPS `guanghulab.com/api/ai/v1/...` 端点; +- 允许的 `domainIds` 与 `local-terminal / cloud-resident` 节点类型; +- `ACTIVE / REVOKED` 状态。 + +同一域与节点类型不得存在两个活跃端点,避免客户端自行猜测权威。 + +## 登记声明契约 + +`gh-aios.node-registration-claim/v1` 只表达灯塔登记服务已经验证的账号—节点—公钥绑定。 +声明最长有效 24 小时,必须周期刷新。它不代替后续登录挑战中的实时节点私钥持有证明,也不 +代替域成员权限、短期会话能力、签名域清单和在线连接回执。 + +## 当前现实边界 + +- 当前公共锚点没有 `maps.node_registration_endpoints`; +- 当前桌面没有可调用的登记声明端点; +- 当前没有生产节点登记签发公钥、登记声明或节点密钥挑战回执; +- 因而 Forgejo 账号验证以后仍停留在“需要节点登记”,这是正确的失败关闭结果。 diff --git a/product-source/hololake-platform/architecture/README.md b/product-source/hololake-platform/architecture/README.md index c04839e..f4f6066 100644 --- a/product-source/hololake-platform/architecture/README.md +++ b/product-source/hololake-platform/architecture/README.md @@ -8,6 +8,7 @@ - [`HOLOLAKE-INTENT-REASONING-MAP-20260809.md`](HOLOLAKE-INTENT-REASONING-MAP-20260809.md):当前产品意图、纠正、思维推理与因果链。 - [`HOLOLAKE-SYSTEM-ARCHITECTURE-20260809.md`](HOLOLAKE-SYSTEM-ARCHITECTURE-20260809.md):`HLP-CURRENT-ARCH-001` 当前完整系统蓝图,覆盖 GH-AIOS 灯塔、五域独立入口、光湖本源域、节点登录与光湖桥。 - [`HOLOLAKE-DESKTOP-0.8.0-CURRENT-CAPABILITY-GAP-20260809.md`](HOLOLAKE-DESKTOP-0.8.0-CURRENT-CAPABILITY-GAP-20260809.md):桌面 0.8.0 已观察能力与最终规划的逐项差距;同时登记线上产品源码仍为 0.4.6 的分叉事实。 +- [`HOLOLAKE-NODE-REGISTRATION-CONTRACT-20260810.md`](HOLOLAKE-NODE-REGISTRATION-CONTRACT-20260810.md):账号验证与节点登记分离后的签名声明、端点注册表、失败关闭和当前生产缺口验收基线。 - [`HOLOLAKE-LANGUAGE-PERSONA-OS.md`](HOLOLAKE-LANGUAGE-PERSONA-OS.md):HoloLake 作为 AI 语言人格驱动操作系统的当前正式产品定位、操作系统映射、原生应用边界与实现缺口。 - [`HOLOLAKE-INTENT-REASONING-MAP-20260807.md`](HOLOLAKE-INTENT-REASONING-MAP-20260807.md):2026-08-07 历史意图层,继续保存形成过程;不再作为当前默认入口。