Require current node possession proof

This commit is contained in:
冰朔 2026-08-10 08:00:53 +08:00
commit 64062fadac
10 changed files with 417 additions and 10 deletions

View file

@ -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<typeof nodePossessionChallengeSigningBytes>[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; } },
);

View file

@ -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<Readonly<DomainAccessProjection>> {
async domainAccess(domainId: string, nodeType: DomainNodeType, at?: number): Promise<Readonly<DomainAccessProjection>> {
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);

View file

@ -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,

View file

@ -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',
};
}

View file

@ -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<typeof createNodePossessionChallenge>) {
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/);
});

View file

@ -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<object>();
const consumedChallenges = new WeakSet<object>();
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<NodePossessionChallenge>): Promise<unknown | null>;
}
function invalidProof(): never {
throw new Error('node_possession_proof_invalid');
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function hasExactKeys(record: Record<string, unknown>, 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<NodePossessionChallenge> {
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;
}

View file

@ -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<object>();
const verifiedClaims = new WeakSet<object>();
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;
}

View file

@ -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() {
// 当前灯塔尚未发布签名运行体交接端点。保持关闭,不从账号身份推导运行体权限。

View file

@ -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`。代码验证器存在不等于节点密钥、
生产密钥库桥、灯塔会话签发或域连接已经存在。

View file

@ -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 历史意图层,继续保存形成过程;不再作为当前默认入口。