From 64062fadaced14ac4e180a7f75fa6ad27b413dc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=86=B0=E6=9C=94?= <565183519@qq.com> Date: Mon, 10 Aug 2026 08:00:53 +0800 Subject: [PATCH 1/2] Require current node possession proof --- .../server/domain-access-orchestrator.test.ts | 43 ++++- .../server/domain-access-orchestrator.ts | 23 ++- .../server/domain-access.test.ts | 23 ++- .../server/domain-access.ts | 8 +- .../server/node-possession-proof.test.ts | 132 +++++++++++++++ .../server/node-possession-proof.ts | 158 ++++++++++++++++++ .../server/node-registration.ts | 12 +- .../hololake-desktop/electron/main.ts | 5 + ...AKE-NODE-REGISTRATION-CONTRACT-20260810.md | 21 +++ .../hololake-platform/architecture/README.md | 2 +- 10 files changed, 417 insertions(+), 10 deletions(-) create mode 100644 product-source/guanghu-knowledge-base/server/node-possession-proof.test.ts create mode 100644 product-source/guanghu-knowledge-base/server/node-possession-proof.ts 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 6157958c3..e2e53afe6 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 @@ -4,6 +4,7 @@ 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 { nodePossessionChallengeSigningBytes } from './node-possession-proof.js'; import { NodeRegistrationSnapshotLoader } from './node-registration-snapshot.js'; import { TrustedSignerSnapshotLoader } from './trusted-signer-snapshot.js'; @@ -18,6 +19,11 @@ const SIGNER_REGISTRY_URL = `https://guanghulab.com/code/bingshuo/guanghu-ice-he 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'); +const nodeKeys = generateKeyPairSync('ed25519'); +const nodePublicKeyPem = nodeKeys.publicKey.export({ format: 'pem', type: 'spki' }).toString(); +const nodeKeyFingerprint = createHash('sha256') + .update(nodeKeys.publicKey.export({ format: 'der', type: 'spki' })) + .digest('hex'); function anchor() { return { @@ -118,7 +124,7 @@ function validRegistrationClaim() { expiresAt: NOW + 60_000, issuedAt: NOW - 1_000, nodeId: NODE_ID, - nodeKeyFingerprint: 'c'.repeat(64), + nodeKeyFingerprint, nodeType: NODE_TYPE, schema: 'gh-aios.node-registration-claim/v1' as const, signerId: 'GH-NODE-REG-SIGNER-001', @@ -168,6 +174,16 @@ function validHandoff() { 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(); } }; +const validPossessionSource = { + async prove(challenge: Parameters[0]) { + return { + challengeId: challenge.challengeId, + publicKeyPem: nodePublicKeyPem, + schema: 'gh-aios.node-possession-response/v1', + signature: sign(null, nodePossessionChallengeSigningBytes(challenge), nodeKeys.privateKey).toString('base64'), + }; + }, +}; test('projects both trust-source states without asking for claims or handoff before login', async () => { let claimReads = 0; @@ -177,6 +193,7 @@ test('projects both trust-source states without asking for claims or handoff bef registrationLoader(), loggedOutIdentity, { async read() { claimReads += 1; return null; } }, + validPossessionSource, { async read() { handoffReads += 1; return null; } }, ); @@ -200,6 +217,7 @@ test('a signed node registration without a handoff remains identity-only', async observedEndpointId = request.endpoint.endpointId; return validRegistrationClaim(); } }, + validPossessionSource, { async read() { return null; } }, ); @@ -212,6 +230,24 @@ test('a signed node registration without a handoff remains identity-only', async assert.ok(status.blockers.includes('verified_domain_manifest_missing')); }); +test('a signed registration without a fresh node-key proof cannot reach the handoff route', async () => { + let handoffReads = 0; + const orchestrator = new DomainAccessOrchestrator( + signerLoader(), + registrationLoader(), + loggedInIdentity, + validRegistrationSource, + { async prove() { return null; } }, + { async read() { handoffReads += 1; return null; } }, + ); + + const status = await orchestrator.domainAccess(DOMAIN_ID, NODE_TYPE, NOW); + + assert.equal(status.stage, 'login-required'); + assert.ok(status.blockers.includes('verified_node_possession_missing')); + assert.equal(handoffReads, 0); +}); + test('a code-channel account cannot supply registration and never contacts a handoff route', async () => { let handoffReads = 0; const orchestrator = new DomainAccessOrchestrator( @@ -219,6 +255,7 @@ test('a code-channel account cannot supply registration and never contacts a han registrationLoader(), loggedInIdentity, { async read() { return null; } }, + validPossessionSource, { async read() { handoffReads += 1; return validHandoff() as never; } }, ); @@ -235,6 +272,7 @@ test('current registries, signed node claim and verified handoff project runtime registrationLoader(), loggedInIdentity, validRegistrationSource, + validPossessionSource, { async read() { return { handoff: validHandoff(), @@ -259,6 +297,7 @@ test('source failures are reduced to safe status without raw details', async () unavailableRegistration, loggedInIdentity, { async read() { claimReads += 1; throw new Error('secret claim detail'); } }, + { async prove() { throw new Error('secret possession detail'); } }, { async read() { throw new Error('secret handoff detail'); } }, ); @@ -282,6 +321,7 @@ test('a degraded signer may preserve handoff verification while node registratio registrationLoader(), loggedInIdentity, validRegistrationSource, + validPossessionSource, { async read() { return { handoff: validHandoff(), @@ -308,6 +348,7 @@ test('a degraded node-registration registry cannot authorize a claim', async () currentNodeLoader, loggedInIdentity, { async read() { claimReads += 1; return validRegistrationClaim(); } }, + validPossessionSource, { async read() { handoffReads += 1; return null; } }, ); 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 1790bad57..ceeb2ae53 100644 --- a/product-source/guanghu-knowledge-base/server/domain-access-orchestrator.ts +++ b/product-source/guanghu-knowledge-base/server/domain-access-orchestrator.ts @@ -19,6 +19,11 @@ import { type NodeRegistrationSnapshotReceipt, } from './node-registration-snapshot.js'; import type { NodeRegistrationClaimSource } from './node-registration-client.js'; +import { + createNodePossessionChallenge, + verifyNodePossessionResponse, + type NodePossessionProofSource, +} from './node-possession-proof.js'; const IDENTIFIER_PATTERN = /^[A-Z0-9][A-Z0-9._:-]{1,159}$/; @@ -81,10 +86,13 @@ export class DomainAccessOrchestrator { private readonly nodeRegistrationSnapshots: NodeRegistrationSnapshotLoader, private readonly identities: DomainIdentitySource, private readonly nodeRegistrations: NodeRegistrationClaimSource, + private readonly nodePossessions: NodePossessionProofSource, private readonly handoffs: DomainRuntimeHandoffSource, + private readonly clock: () => number = Date.now, ) {} - async domainAccess(domainId: string, nodeType: DomainNodeType, now = Date.now()): Promise> { + async domainAccess(domainId: string, nodeType: DomainNodeType, at?: number): Promise> { + let now = at ?? this.clock(); 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, nodeRegistrationSnapshot, identity] = await Promise.all([ @@ -93,6 +101,7 @@ export class DomainAccessOrchestrator { this.identities.read(domainId, nodeType).then(safeIdentity, () => safeIdentity(null)), ]); let nodeRegistrationVerified = false; + let nodePossessionVerified = false; if (identity.accountVerified && nodeRegistrationSnapshot.receipt.status === 'CURRENT') { try { const loaded = assertLoadedNodeRegistrationSnapshot(nodeRegistrationSnapshot); @@ -106,13 +115,20 @@ export class DomainAccessOrchestrator { nodeType, })); if (claim) { - verifyNodeRegistrationClaim(claim, { + const registration = verifyNodeRegistrationClaim(claim, { accountId: identity.accountId, domainId, nodeId: identity.nodeId, nodeType, }, endpoint, now); nodeRegistrationVerified = true; + const challenge = createNodePossessionChallenge(registration, now); + const response = await this.nodePossessions.prove(challenge); + if (response) { + if (at === undefined) now = this.clock(); + verifyNodePossessionResponse(response, challenge, registration, now); + nodePossessionVerified = true; + } } } } catch { @@ -123,11 +139,12 @@ export class DomainAccessOrchestrator { accountVerified: identity.accountVerified, domainId, nodeId: identity.nodeId, + nodePossessionVerified, nodeRegistrationVerified, nodeType, }; - if (identity.accountVerified && nodeRegistrationVerified) { + if (identity.accountVerified && nodeRegistrationVerified && nodePossessionVerified) { let candidate: DomainRuntimeHandoffCandidate | null = null; try { candidate = await this.handoffs.read(domainId, identity.nodeId, nodeType); 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 43879c59b..7f2949b81 100644 --- a/product-source/guanghu-knowledge-base/server/domain-access.test.ts +++ b/product-source/guanghu-knowledge-base/server/domain-access.test.ts @@ -147,21 +147,21 @@ function snapshotLoader(readRegistry: () => unknown) { } test('a local workspace remains available without claiming domain runtime access', () => { - const status = evaluateDomainAccess({ accountVerified: false, domainId: 'DOM-FIFTH-0001', nodeId: '', nodeRegistrationVerified: false, nodeType: NODE_TYPE }, NOW); + const status = evaluateDomainAccess({ accountVerified: false, domainId: 'DOM-FIFTH-0001', nodeId: '', nodePossessionVerified: false, nodeRegistrationVerified: false, nodeType: NODE_TYPE }, NOW); assert.equal(status.localWorkspaceAllowed, true); assert.equal(status.runtimeReady, false); assert.equal(status.stage, 'login-required'); }); test('a code-channel account cannot substitute for verified node registration', () => { - const status = evaluateDomainAccess({ accountVerified: true, domainId: 'DOM-FIFTH-0001', nodeId: 'JD-FD-PRIMARY', nodeRegistrationVerified: false, nodeType: NODE_TYPE }, NOW); + const status = evaluateDomainAccess({ accountVerified: true, domainId: 'DOM-FIFTH-0001', nodeId: 'JD-FD-PRIMARY', nodePossessionVerified: false, nodeRegistrationVerified: false, nodeType: NODE_TYPE }, NOW); assert.equal(status.stage, 'login-required'); assert.equal(status.blockers.includes('account_identity_missing'), false); assert.ok(status.blockers.includes('verified_node_registration_missing')); }); test('a separately verified node is still not a domain runtime session', () => { - const status = evaluateDomainAccess({ accountVerified: true, domainId: 'DOM-FIFTH-0001', nodeId: 'JD-FD-PRIMARY', nodeRegistrationVerified: true, nodeType: NODE_TYPE }, NOW); + const status = evaluateDomainAccess({ accountVerified: true, domainId: 'DOM-FIFTH-0001', nodeId: 'JD-FD-PRIMARY', nodePossessionVerified: true, nodeRegistrationVerified: true, nodeType: NODE_TYPE }, NOW); assert.equal(status.stage, 'identity-verified'); assert.deepEqual(status.blockers, [ 'verified_domain_manifest_missing', @@ -170,12 +170,26 @@ test('a separately verified node is still not a domain runtime session', () => { ]); }); +test('a signed registration without a current private-key proof remains login-required', () => { + const status = evaluateDomainAccess({ + accountVerified: true, + domainId: 'DOM-FIFTH-0001', + nodeId: 'JD-FD-PRIMARY', + nodePossessionVerified: false, + nodeRegistrationVerified: true, + nodeType: NODE_TYPE, + }, NOW); + assert.equal(status.stage, 'login-required'); + assert.ok(status.blockers.includes('verified_node_possession_missing')); +}); + test('runtime access requires matching manifest, scoped capability and online receipt', () => { const handoff = parseDomainAccessHandoff(validHandoff, 'DOM-FIFTH-0001', 'LOCAL-001', NODE_TYPE, trustedSigner, NOW); const status = evaluateDomainAccess({ accountVerified: true, domainId: 'DOM-FIFTH-0001', nodeId: 'LOCAL-001', + nodePossessionVerified: true, nodeRegistrationVerified: true, nodeType: NODE_TYPE, ...handoff, @@ -203,6 +217,7 @@ test('the runtime handoff entry resolves its signer only from a loaded snapshot' accountVerified: true, domainId: 'DOM-FIFTH-0001', nodeId: 'LOCAL-001', + nodePossessionVerified: true, nodeRegistrationVerified: true, nodeType: NODE_TYPE, ...verified.handoff, @@ -270,6 +285,7 @@ test('mismatched or expired evidence fails closed', () => { domainId: 'DOM-FIFTH-0001', manifest: verifiedHandoff.manifest, nodeId: 'LOCAL-001', + nodePossessionVerified: true, nodeRegistrationVerified: true, nodeType: NODE_TYPE, sessionCapability: { capabilityId: 'CAP-001', domainId: 'DOM-FIFTH-0001', expiresAt: NOW, nodeId: 'LOCAL-001', nodeType: NODE_TYPE, scopes: ['domain:enter'] }, @@ -343,6 +359,7 @@ test('copying verified-looking fields cannot bypass the in-process verifier boun verifierReceipt: { ...verifiedHandoff.manifest.verifierReceipt }, }, nodeId: 'LOCAL-001', + nodePossessionVerified: true, nodeRegistrationVerified: true, nodeType: NODE_TYPE, sessionCapability: verifiedHandoff.sessionCapability, diff --git a/product-source/guanghu-knowledge-base/server/domain-access.ts b/product-source/guanghu-knowledge-base/server/domain-access.ts index 491765469..3223430c7 100644 --- a/product-source/guanghu-knowledge-base/server/domain-access.ts +++ b/product-source/guanghu-knowledge-base/server/domain-access.ts @@ -79,6 +79,7 @@ export interface DomainAccessEvidence { domainId: string; manifest?: DomainManifestEvidence; nodeId: string; + nodePossessionVerified: boolean; nodeRegistrationVerified: boolean; nodeType: DomainNodeType; sessionCapability?: DomainSessionCapability; @@ -244,6 +245,7 @@ export function evaluateDomainAccess(evidence: DomainAccessEvidence, now = Date. const blockers: string[] = []; if (!evidence.accountVerified) blockers.push('account_identity_missing'); if (!evidence.nodeRegistrationVerified || !evidence.nodeId) blockers.push('verified_node_registration_missing'); + if (!evidence.nodePossessionVerified) blockers.push('verified_node_possession_missing'); const manifest = evidence.manifest; const verifier = manifest?.verifierReceipt; if (!manifest @@ -285,6 +287,10 @@ export function evaluateDomainAccess(evidence: DomainAccessEvidence, now = Date. nodeId: evidence.nodeId, nodeType: evidence.nodeType, runtimeReady, - stage: runtimeReady ? 'runtime-ready' : evidence.accountVerified && evidence.nodeRegistrationVerified && Boolean(evidence.nodeId) ? 'identity-verified' : 'login-required', + stage: runtimeReady + ? 'runtime-ready' + : evidence.accountVerified && evidence.nodeRegistrationVerified && evidence.nodePossessionVerified && Boolean(evidence.nodeId) + ? 'identity-verified' + : 'login-required', }; } diff --git a/product-source/guanghu-knowledge-base/server/node-possession-proof.test.ts b/product-source/guanghu-knowledge-base/server/node-possession-proof.test.ts new file mode 100644 index 000000000..58b5618b2 --- /dev/null +++ b/product-source/guanghu-knowledge-base/server/node-possession-proof.test.ts @@ -0,0 +1,132 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createHash, generateKeyPairSync, sign } from 'node:crypto'; +import { + createNodePossessionChallenge, + nodePossessionChallengeSigningBytes, + verifyNodePossessionResponse, +} from './node-possession-proof.js'; +import { + nodeRegistrationClaimSigningBytes, + parseNodeRegistrationEndpointRegistry, + resolveNodeRegistrationEndpoint, + verifyNodeRegistrationClaim, +} from './node-registration.js'; + +const NOW = 1_786_291_200_000; +const DOMAIN_ID = 'DOM-FIFTH-0001'; +const NODE_ID = 'LOCAL-001'; +const registrationKeys = generateKeyPairSync('ed25519'); +const nodeKeys = generateKeyPairSync('ed25519'); +const nodePublicKeyPem = nodeKeys.publicKey.export({ format: 'pem', type: 'spki' }).toString(); +const fingerprint = createHash('sha256') + .update(nodeKeys.publicKey.export({ format: 'der', type: 'spki' })) + .digest('hex'); + +function verifiedRegistration() { + const registry = parseNodeRegistrationEndpointRegistry({ + endpoints: [{ + algorithm: 'Ed25519', + domainIds: [DOMAIN_ID], + endpointId: 'GH-NODE-REG-001', + nodeTypes: ['local-terminal'], + 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: '1.0.0', + }, { + repositoryId: 'REPO-012', + sourceCommit: 'a'.repeat(40), + sourceUrl: `https://guanghulab.com/code/bingshuo/guanghu-ice-heart/raw/commit/${'a'.repeat(40)}/routing/node-registration-endpoints.json`, + }); + const endpoint = resolveNodeRegistrationEndpoint(registry, { + domainId: DOMAIN_ID, + nodeType: 'local-terminal', + }); + assert.ok(endpoint); + const payload = { + accountId: 'bingshuo', + claimId: 'NODE-CLAIM-001', + domainId: DOMAIN_ID, + endpointId: endpoint.endpointId, + expiresAt: NOW + 120_000, + issuedAt: NOW - 1_000, + nodeId: NODE_ID, + nodeKeyFingerprint: fingerprint, + nodeType: 'local-terminal' as const, + schema: 'gh-aios.node-registration-claim/v1' as const, + signerId: endpoint.signerId, + }; + return verifyNodeRegistrationClaim({ + ...payload, + signature: sign(null, nodeRegistrationClaimSigningBytes(payload), registrationKeys.privateKey).toString('base64'), + }, { + accountId: payload.accountId, + domainId: DOMAIN_ID, + nodeId: NODE_ID, + nodeType: 'local-terminal', + }, endpoint, NOW); +} + +function responseFor(challenge: ReturnType) { + return { + challengeId: challenge.challengeId, + publicKeyPem: nodePublicKeyPem, + schema: 'gh-aios.node-possession-response/v1', + signature: sign(null, nodePossessionChallengeSigningBytes(challenge), nodeKeys.privateKey).toString('base64'), + }; +} + +test('verifies a fresh proof from the key fingerprint bound into the signed registration', () => { + const registration = verifiedRegistration(); + const challenge = createNodePossessionChallenge( + registration, + NOW, + 'NODE-CHALLENGE-001', + 'A'.repeat(43), + ); + const response = responseFor(challenge); + assert.equal(verifyNodePossessionResponse(response, challenge, registration, NOW + 1), true); + assert.throws(() => verifyNodePossessionResponse(response, challenge, registration, NOW + 2), /proof_invalid/); +}); + +test('rejects a forged challenge that was not issued by the verifier', () => { + const registration = verifiedRegistration(); + const challenge = { + ...createNodePossessionChallenge(registration, NOW, 'NODE-CHALLENGE-001', 'A'.repeat(43)), + nonce: 'B'.repeat(43), + }; + assert.throws(() => verifyNodePossessionResponse(responseFor(challenge), challenge, registration, NOW + 1), /proof_invalid/); +}); + +test('rejects a response signed by a different node key', () => { + const registration = verifiedRegistration(); + const challenge = createNodePossessionChallenge(registration, NOW, 'NODE-CHALLENGE-001', 'A'.repeat(43)); + const otherKeys = generateKeyPairSync('ed25519'); + const response = { + challengeId: challenge.challengeId, + publicKeyPem: otherKeys.publicKey.export({ format: 'pem', type: 'spki' }).toString(), + schema: 'gh-aios.node-possession-response/v1', + signature: sign(null, nodePossessionChallengeSigningBytes(challenge), otherKeys.privateKey).toString('base64'), + }; + assert.throws(() => verifyNodePossessionResponse(response, challenge, registration, NOW + 1), /proof_invalid/); +}); + +test('rejects expired, replayed-to-another-challenge and unknown-field responses', () => { + const registration = verifiedRegistration(); + const first = createNodePossessionChallenge(registration, NOW, 'NODE-CHALLENGE-001', 'A'.repeat(43)); + const second = createNodePossessionChallenge(registration, NOW, 'NODE-CHALLENGE-002', 'B'.repeat(43)); + assert.throws(() => verifyNodePossessionResponse(responseFor(first), second, registration, NOW + 1), /proof_invalid/); + assert.throws(() => verifyNodePossessionResponse({ ...responseFor(first), extra: true }, first, registration, NOW + 1), /proof_invalid/); + assert.throws(() => verifyNodePossessionResponse(responseFor(first), first, registration, NOW + 60_000), /proof_invalid/); +}); + +test('does not accept a structurally copied registration as verified provenance', () => { + const copied = { ...verifiedRegistration() }; + assert.throws(() => createNodePossessionChallenge(copied, NOW, 'NODE-CHALLENGE-001', 'A'.repeat(43)), /proof_invalid/); +}); diff --git a/product-source/guanghu-knowledge-base/server/node-possession-proof.ts b/product-source/guanghu-knowledge-base/server/node-possession-proof.ts new file mode 100644 index 000000000..2cd568222 --- /dev/null +++ b/product-source/guanghu-knowledge-base/server/node-possession-proof.ts @@ -0,0 +1,158 @@ +import { + createHash, + createPublicKey, + randomBytes, + randomUUID, + verify as verifySignature, +} from 'node:crypto'; +import { + assertVerifiedNodeRegistration, + type VerifiedNodeRegistration, +} from './node-registration.js'; + +const CHALLENGE_SCHEMA = 'gh-aios.node-possession-challenge/v1' as const; +const RESPONSE_SCHEMA = 'gh-aios.node-possession-response/v1' as const; +const CHALLENGE_LIFETIME_MS = 60_000; +const IDENTIFIER_PATTERN = /^[A-Z0-9][A-Z0-9._:-]{1,159}$/; +const NONCE_PATTERN = /^[A-Za-z0-9_-]{43}$/; +const issuedChallenges = new WeakSet(); +const consumedChallenges = new WeakSet(); + +export interface NodePossessionChallenge { + accountId: string; + challengeId: string; + claimId: string; + domainId: string; + expiresAt: number; + issuedAt: number; + nodeId: string; + nodeKeyFingerprint: string; + nodeType: VerifiedNodeRegistration['nodeType']; + nonce: string; + schema: typeof CHALLENGE_SCHEMA; +} + +export interface NodePossessionResponse { + challengeId: string; + publicKeyPem: string; + schema: typeof RESPONSE_SCHEMA; + signature: string; +} + +export interface NodePossessionProofSource { + prove(challenge: Readonly): Promise; +} + +function invalidProof(): never { + throw new Error('node_possession_proof_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]); +} + +export function nodePossessionChallengeSigningBytes(challenge: NodePossessionChallenge): Buffer { + return Buffer.from(JSON.stringify({ + accountId: challenge.accountId, + challengeId: challenge.challengeId, + claimId: challenge.claimId, + domainId: challenge.domainId, + expiresAt: challenge.expiresAt, + issuedAt: challenge.issuedAt, + nodeId: challenge.nodeId, + nodeKeyFingerprint: challenge.nodeKeyFingerprint, + nodeType: challenge.nodeType, + nonce: challenge.nonce, + schema: challenge.schema, + }), 'utf8'); +} + +export function createNodePossessionChallenge( + registration: VerifiedNodeRegistration, + now = Date.now(), + challengeId = `NODE-CHALLENGE-${randomUUID().toUpperCase()}`, + nonce = randomBytes(32).toString('base64url'), +): Readonly { + try { + assertVerifiedNodeRegistration(registration); + } catch { + invalidProof(); + } + if (!Number.isSafeInteger(now) + || now < registration.issuedAt + || now >= registration.expiresAt + || !IDENTIFIER_PATTERN.test(challengeId) + || !NONCE_PATTERN.test(nonce)) invalidProof(); + const expiresAt = Math.min(now + CHALLENGE_LIFETIME_MS, registration.expiresAt); + if (expiresAt <= now) invalidProof(); + const challenge = Object.freeze({ + accountId: registration.accountId, + challengeId, + claimId: registration.claimId, + domainId: registration.domainId, + expiresAt, + issuedAt: now, + nodeId: registration.nodeId, + nodeKeyFingerprint: registration.nodeKeyFingerprint, + nodeType: registration.nodeType, + nonce, + schema: CHALLENGE_SCHEMA, + }); + issuedChallenges.add(challenge); + return challenge; +} + +export function verifyNodePossessionResponse( + input: unknown, + challenge: NodePossessionChallenge, + registration: VerifiedNodeRegistration, + now = Date.now(), +): true { + try { + assertVerifiedNodeRegistration(registration); + } catch { + invalidProof(); + } + if (!issuedChallenges.has(challenge) + || consumedChallenges.has(challenge) + || !Number.isSafeInteger(now) + || now < challenge.issuedAt + || now >= challenge.expiresAt + || registration.expiresAt <= now + || challenge.accountId !== registration.accountId + || challenge.claimId !== registration.claimId + || challenge.domainId !== registration.domainId + || challenge.nodeId !== registration.nodeId + || challenge.nodeKeyFingerprint !== registration.nodeKeyFingerprint + || challenge.nodeType !== registration.nodeType + || !isRecord(input) + || !hasExactKeys(input, ['challengeId', 'publicKeyPem', 'schema', 'signature']) + || input.schema !== RESPONSE_SCHEMA + || input.challengeId !== challenge.challengeId + || typeof input.publicKeyPem !== 'string' + || input.publicKeyPem.length > 4096 + || input.publicKeyPem.includes('PRIVATE KEY') + || typeof input.signature !== 'string') invalidProof(); + + try { + const publicKey = createPublicKey({ key: input.publicKeyPem, format: 'pem' }); + if (publicKey.asymmetricKeyType !== 'ed25519') invalidProof(); + const fingerprint = createHash('sha256') + .update(publicKey.export({ format: 'der', type: 'spki' })) + .digest('hex'); + if (fingerprint !== registration.nodeKeyFingerprint) invalidProof(); + const signature = Buffer.from(input.signature, 'base64'); + if (signature.length !== 64 || signature.toString('base64') !== input.signature) invalidProof(); + if (!verifySignature(null, nodePossessionChallengeSigningBytes(challenge), publicKey, signature)) invalidProof(); + } catch { + invalidProof(); + } + consumedChallenges.add(challenge); + return true; +} diff --git a/product-source/guanghu-knowledge-base/server/node-registration.ts b/product-source/guanghu-knowledge-base/server/node-registration.ts index 0cbb002c9..7f4c572b1 100644 --- a/product-source/guanghu-knowledge-base/server/node-registration.ts +++ b/product-source/guanghu-knowledge-base/server/node-registration.ts @@ -11,6 +11,7 @@ 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(); +const verifiedClaims = new WeakSet(); export interface NodeRegistrationEndpoint { algorithm: 'Ed25519'; @@ -57,6 +58,13 @@ export interface VerifiedNodeRegistration extends NodeRegistrationClaimPayload { verifiedAt: number; } +export function assertVerifiedNodeRegistration( + claim: VerifiedNodeRegistration, +): VerifiedNodeRegistration { + if (!verifiedClaims.has(claim)) invalidClaim(); + return claim; +} + export interface NodeRegistrationLookup { domainId: string; nodeType: DomainNodeType; @@ -270,9 +278,11 @@ export function verifyNodeRegistrationClaim( } catch { invalidClaim(); } - return Object.freeze({ + const verified = Object.freeze({ ...payload, signature: input.signature, verifiedAt: now, }); + verifiedClaims.add(verified); + return verified; } diff --git a/product-source/hololake-desktop/electron/main.ts b/product-source/hololake-desktop/electron/main.ts index d132b1763..9de9395c8 100644 --- a/product-source/hololake-desktop/electron/main.ts +++ b/product-source/hololake-desktop/electron/main.ts @@ -361,6 +361,11 @@ const domainAccessOrchestrator = new DomainAccessOrchestrator( }, }, nodeRegistrationClaims, + { + // The production node-keystore bridge is not registered yet. Returning no + // proof keeps the domain gate closed even if a future signed claim appears. + async prove() { 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 index 976dc9c05..ab72b5965 100644 --- a/product-source/hololake-platform/architecture/HOLOLAKE-NODE-REGISTRATION-CONTRACT-20260810.md +++ b/product-source/hololake-platform/architecture/HOLOLAKE-NODE-REGISTRATION-CONTRACT-20260810.md @@ -58,3 +58,24 @@ 当前公共锚点仍未登记端点,所以该传输在生产运行中不可达,也不会发出请求;实现存在不等于 端点服务、节点登记或域运行体已经存在。 + +## 节点私钥持有证明 · 阶段 003 + +签名登记声明只证明灯塔曾登记“节点编号—节点公钥指纹”,不能证明当前发起登录的终端此刻 +仍持有对应私钥。HoloLake 必须在声明验证以后创建一次性 +`gh-aios.node-possession-challenge/v1`:精确绑定账号、登记声明、域、节点编号、节点类型、 +公钥指纹、随机 256 位 nonce、挑战编号、签发时间与到期时间;有效期最多 60 秒且不得超过 +登记声明自身到期时间。 + +节点返回 `gh-aios.node-possession-response/v1`,只包含挑战编号、Ed25519 公钥和对完整挑战 +规范字节的签名。验证器必须确认: + +1. 挑战确由当前 HoloLake 进程创建,调用方复制相同字段不能伪造来源; +2. 响应公钥的 SPKI DER SHA-256 与签名登记声明中的 `nodeKeyFingerprint` 完全一致; +3. Ed25519 签名、挑战编号、账号、声明、域、节点、类型、nonce 和有效期全部匹配; +4. 未知字段、错误公钥、过期挑战、跨挑战重放、复制的登记对象或任一解析错误全部失败关闭。 + +只有“账号验证 + 签名节点登记 + 当前节点私钥持有证明”三者同时成立,客户端才允许请求域 +运行体交接。当前桌面尚无生产节点密钥库桥,正式运行源固定返回无证明,因此即使未来出现一份 +有效登记声明,也不能越过 `verified_node_possession_missing`。代码验证器存在不等于节点密钥、 +生产密钥库桥、灯塔会话签发或域连接已经存在。 diff --git a/product-source/hololake-platform/architecture/README.md b/product-source/hololake-platform/architecture/README.md index f7979dc43..37241d6cc 100644 --- a/product-source/hololake-platform/architecture/README.md +++ b/product-source/hololake-platform/architecture/README.md @@ -8,7 +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-NODE-REGISTRATION-CONTRACT-20260810.md`](HOLOLAKE-NODE-REGISTRATION-CONTRACT-20260810.md):账号、签名节点登记与当前节点私钥持有证明分离后的端点注册表、无凭据声明读取、60 秒一次性 Ed25519 挑战、失败关闭和当前生产缺口验收基线。 - [`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 历史意图层,继续保存形成过程;不再作为当前默认入口。 From ca67ba92d252e87249054060c85fe46a2b757133 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=86=B0=E6=9C=94?= <565183519@qq.com> Date: Mon, 10 Aug 2026 08:11:16 +0800 Subject: [PATCH 2/2] Record node possession proof delivery --- ...KE-NODE-POSSESSION-PROOF-20260810-001.json | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 deployment/receipts/GH-HOLOLAKE-NODE-POSSESSION-PROOF-20260810-001.json diff --git a/deployment/receipts/GH-HOLOLAKE-NODE-POSSESSION-PROOF-20260810-001.json b/deployment/receipts/GH-HOLOLAKE-NODE-POSSESSION-PROOF-20260810-001.json new file mode 100644 index 000000000..6818ff6a6 --- /dev/null +++ b/deployment/receipts/GH-HOLOLAKE-NODE-POSSESSION-PROOF-20260810-001.json @@ -0,0 +1,49 @@ +{ + "schema": "guanghu.hololake-stage-delivery-receipt/v1", + "receipt_id": "GH-HOLOLAKE-NODE-POSSESSION-PROOF-20260810-001", + "development_id": "DEV-20260809-007", + "issued_at": "2026-08-10T00:10:25Z", + "source": { + "repository": "REPO-014", + "branch": "main", + "implementation_commit": "64062fadaced14ac4e180a7f75fa6ad27b413dc5", + "implementation_tree": "40bf0f0aa7331bf5d365d67fc6457b38d4b87ca7" + }, + "stage": { + "id": "NODE-POSSESSION-PROOF-003", + "state": "IMPLEMENTED_AND_LOCALLY_DEPLOYED", + "acceptance": [ + "challenge_is_created_only_from_an_in_process_cryptographically_verified_registration", + "challenge_binds_account_claim_domain_node_type_fingerprint_nonce_and_expiry", + "response_ed25519_public_key_spki_sha256_matches_the_registered_fingerprint", + "challenge_is_single_use_and_expires_within_60_seconds", + "unknown_fields_wrong_keys_replay_expiry_and_copied_provenance_fail_closed", + "domain_handoff_is_not_requested_without_current_node_private_key_possession" + ] + }, + "verification": { + "targeted_tests": "38_PASS", + "desktop_tests": "84_PASS", + "knowledge_base_production_build": "PASS", + "desktop_production_build": "PASS", + "ghnqg": "GHNQG_PASS_100", + "ghnqg_receipt_sha256": "aaa5fbbe0d37d0dbd6ee442beac69bf8dc1bc583093e165202c8d7bc0018cdb3", + "candidate_codesign": "PASS_DEEP_STRICT", + "installed_codesign": "PASS_DEEP_STRICT", + "installed_app_asar_sha256": "870ec9afb0f62f9917d968cfe68aa564f97510716ddc4788b71985a4d44823a3", + "installed_executable_sha256": "c423c2db1708579189814d02f86f474db723bce836e8beb9ba449f2e90ca1287", + "installed_runtime_pid": 40029, + "notarization": "SKIPPED_OPTIONS_UNAVAILABLE" + }, + "recovery": { + "previous_desktop_bundle": "/Users/bingshuolingdianyuanhe/Documents/Codex/2026-08-09/new-chat/work/DEV-20260809-007/desktop-backup-node-possession-20260810-0811/HoloLake Era.app" + }, + "reality_boundary": { + "repo_012_main": "34e1949a113be8d4871e61c094f2ae06e0bc5a45", + "production_node_registration_registry": "NOT_EXISTS_0", + "production_node_keystore_bridge": "NOT_EXISTS_0", + "production_node_possession_receipt": "NOT_EXISTS_0", + "production_domain_session": "NOT_EXISTS_0", + "production_behavior": "NO_REGISTRY_MEANS_NO_CLAIM_REQUEST_AND_NO_POSSESSION_CHALLENGE" + } +}