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] 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 6157958..e2e53af 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 1790bad..ceeb2ae 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 43879c5..7f2949b 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 4917654..3223430 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 0000000..58b5618 --- /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 0000000..2cd5682 --- /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 0cbb002..7f4c572 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 d132b17..9de9395 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 976dc9c..ab72b59 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 f7979dc..37241d6 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 历史意图层,继续保存形成过程;不再作为当前默认入口。