Bind runtime handoff to node proof

This commit is contained in:
冰朔 2026-08-10 08:25:34 +08:00
commit 46d19aeb45
7 changed files with 249 additions and 55 deletions

View file

@ -23,6 +23,7 @@ import {
createNodePossessionChallenge,
verifyNodePossessionResponse,
type NodePossessionProofSource,
type VerifiedNodePossession,
} from './node-possession-proof.js';
const IDENTIFIER_PATTERN = /^[A-Z0-9][A-Z0-9._:-]{1,159}$/;
@ -46,7 +47,7 @@ export interface DomainRuntimeHandoffCandidate {
}
export interface DomainRuntimeHandoffSource {
read(domainId: string, nodeId: string, nodeType: DomainNodeType): Promise<DomainRuntimeHandoffCandidate | null>;
read(possession: VerifiedNodePossession): Promise<DomainRuntimeHandoffCandidate | null>;
}
export interface DomainAccessProjection {
@ -102,6 +103,7 @@ export class DomainAccessOrchestrator {
]);
let nodeRegistrationVerified = false;
let nodePossessionVerified = false;
let possession: VerifiedNodePossession | null = null;
if (identity.accountVerified && nodeRegistrationSnapshot.receipt.status === 'CURRENT') {
try {
const loaded = assertLoadedNodeRegistrationSnapshot(nodeRegistrationSnapshot);
@ -126,7 +128,7 @@ export class DomainAccessOrchestrator {
const response = await this.nodePossessions.prove(challenge);
if (response) {
if (at === undefined) now = this.clock();
verifyNodePossessionResponse(response, challenge, registration, now);
possession = verifyNodePossessionResponse(response, challenge, registration, now);
nodePossessionVerified = true;
}
}
@ -144,10 +146,10 @@ export class DomainAccessOrchestrator {
nodeType,
};
if (identity.accountVerified && nodeRegistrationVerified && nodePossessionVerified) {
if (identity.accountVerified && nodeRegistrationVerified && nodePossessionVerified && possession) {
let candidate: DomainRuntimeHandoffCandidate | null = null;
try {
candidate = await this.handoffs.read(domainId, identity.nodeId, nodeType);
candidate = await this.handoffs.read(possession);
} catch {
candidate = null;
}

View file

@ -1,75 +1,163 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createHash, generateKeyPairSync, sign } from 'node:crypto';
import { StrictDomainRuntimeHandoffSource } from './domain-runtime-handoff-source.js';
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 = 'JD-FD-PRIMARY';
const NODE_TYPE = 'cloud-resident' as const;
const registrationKeys = generateKeyPairSync('ed25519');
const nodeKeys = generateKeyPairSync('ed25519');
const publicKeyPem = nodeKeys.publicKey.export({ format: 'pem', type: 'spki' }).toString();
const fingerprint = createHash('sha256')
.update(nodeKeys.publicKey.export({ format: 'der', type: 'spki' }))
.digest('hex');
function response(overrides: Record<string, unknown> = {}) {
function verifiedPossession() {
const registry = parseNodeRegistrationEndpointRegistry({
endpoints: [{
algorithm: 'Ed25519',
domainIds: [DOMAIN_ID],
endpointId: 'GH-NODE-REG-001',
nodeTypes: [NODE_TYPE],
publicKeyPem: registrationKeys.publicKey.export({ format: 'pem', type: 'spki' }).toString(),
signerId: 'GH-NODE-REG-SIGNER-001',
status: 'ACTIVE',
url: 'https://guanghulab.com/api/ai/v1/node-registrations/claims',
}],
registryId: 'GH-AIOS-NODE-REGISTRATION-ENDPOINTS-001',
schema: 'gh-aios.node-registration-endpoints/v1',
state: 'CURRENT',
version: '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: NODE_TYPE });
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: NODE_TYPE,
schema: 'gh-aios.node-registration-claim/v1' as const,
signerId: endpoint.signerId,
};
const registration = verifyNodeRegistrationClaim({
...payload,
signature: sign(null, nodeRegistrationClaimSigningBytes(payload), registrationKeys.privateKey).toString('base64'),
}, {
accountId: payload.accountId,
domainId: DOMAIN_ID,
nodeId: NODE_ID,
nodeType: NODE_TYPE,
}, endpoint, NOW);
const challenge = createNodePossessionChallenge(
registration,
NOW,
'NODE-CHALLENGE-001',
'A'.repeat(43),
);
const response = {
challengeId: challenge.challengeId,
publicKeyPem,
schema: 'gh-aios.node-possession-response/v1' as const,
signature: sign(null, nodePossessionChallengeSigningBytes(challenge), nodeKeys.privateKey).toString('base64'),
};
return verifyNodePossessionResponse(response, challenge, registration, NOW + 1);
}
function response(requestId: string, overrides: Record<string, unknown> = {}) {
return {
schema: 'gh-aios.domain-runtime-handoff-response/v1',
handoff: { manifest: { digest: 'untrusted-until-orchestrator-verifies' } },
request_id: requestId,
signer_lookup: {
repository_id: 'REPO-014',
signer_id: 'GH-LIGHTHOUSE-001',
},
status: 'ISSUED',
...overrides,
};
}
test('adapts one exact endpoint-neutral response without interpreting signed evidence', async () => {
test('forwards one exact cryptographic authorization bundle without private material', async () => {
const requests: unknown[] = [];
const source = new StrictDomainRuntimeHandoffSource({
async request(input) {
requests.push(input);
return response();
return response(input.requestId);
},
});
}, () => NOW + 2, () => 'HANDOFF-REQ-001');
const candidate = await source.read(DOMAIN_ID, NODE_ID, NODE_TYPE);
const candidate = await source.read(verifiedPossession());
assert.deepEqual(requests, [{ domainId: DOMAIN_ID, nodeId: NODE_ID, nodeType: NODE_TYPE }]);
assert.equal(requests.length, 1);
assert.deepEqual(Object.keys(requests[0] as object).sort(), [
'accountId', 'challenge', 'domainId', 'issuedAt', 'nodeId', 'nodeType',
'registration', 'requestId', 'response', 'schema',
]);
assert.equal(JSON.stringify(requests[0]).includes('PRIVATE KEY'), false);
assert.deepEqual(candidate, {
handoff: { manifest: { digest: 'untrusted-until-orchestrator-verifies' } },
signerLookup: { repositoryId: 'REPO-014', signerId: 'GH-LIGHTHOUSE-001' },
});
assert.equal(Object.isFrozen(candidate), true);
assert.equal(Object.isFrozen(candidate?.signerLookup), true);
});
test('rejects malformed, extended and self-authorizing response envelopes', async () => {
test('binds the response to the generated request and rejects malformed envelopes', async () => {
const samples = [
null,
response({ extra: true }),
response({ schema: 'other/v1' }),
response({ signer_lookup: { repository_id: 'REPO-014', signer_id: 'bad signer' } }),
response({ signer_lookup: { repository_id: 'REPO-014', signer_id: 'GH-LIGHTHOUSE-001', public_key: 'forbidden' } }),
response({ handoff: null }),
response('OTHER-REQ'),
response('HANDOFF-REQ-001', { extra: true }),
response('HANDOFF-REQ-001', { schema: 'other/v1' }),
response('HANDOFF-REQ-001', { signer_lookup: { repository_id: 'REPO-014', signer_id: 'bad signer' } }),
response('HANDOFF-REQ-001', { signer_lookup: { repository_id: 'REPO-014', signer_id: 'GH-LIGHTHOUSE-001', public_key: 'forbidden' } }),
response('HANDOFF-REQ-001', { handoff: null }),
response('HANDOFF-REQ-001', { handoff: null, signer_lookup: null, status: 'NOT_AUTHORIZED' }),
];
let index = 0;
const source = new StrictDomainRuntimeHandoffSource({
async request() { return samples[index++]; },
});
for (const _sample of samples) {
assert.equal(await source.read(DOMAIN_ID, NODE_ID, NODE_TYPE), null);
for (const sample of samples) {
const source = new StrictDomainRuntimeHandoffSource({ async request() { return sample; } }, () => NOW + 2, () => 'HANDOFF-REQ-001');
assert.equal(await source.read(verifiedPossession()), null);
}
});
test('fails closed on invalid request identity and transport errors without leaking details', async () => {
test('rejects copied, expired and replayed possession authorizations before transport', async () => {
let requests = 0;
const possession = verifiedPossession();
const source = new StrictDomainRuntimeHandoffSource({
async request() {
requests += 1;
throw new Error('secret remote detail');
},
});
assert.equal(await source.read('bad domain', NODE_ID, NODE_TYPE), null);
assert.equal(requests, 0);
assert.equal(await source.read(DOMAIN_ID, NODE_ID, NODE_TYPE), null);
assert.equal(requests, 1);
assert.equal(await source.read(DOMAIN_ID, NODE_ID, 'invalid' as never), null);
async request(input) { requests += 1; return response(input.requestId); },
}, () => NOW + 2, () => 'HANDOFF-REQ-001');
assert.ok(await source.read(possession));
assert.equal(await source.read(possession), null);
assert.equal(await source.read({ ...verifiedPossession() }), null);
const expired = new StrictDomainRuntimeHandoffSource({
async request(input) { requests += 1; return response(input.requestId); },
}, () => NOW + 60_000, () => 'HANDOFF-REQ-002');
assert.equal(await expired.read(verifiedPossession()), null);
assert.equal(requests, 1);
});
test('fails closed on transport errors without exposing remote details', async () => {
const source = new StrictDomainRuntimeHandoffSource({
async request() { throw new Error('secret remote detail'); },
}, () => NOW + 2, () => 'HANDOFF-REQ-001');
assert.equal(await source.read(verifiedPossession()), null);
});

View file

@ -2,14 +2,31 @@ import type {
DomainRuntimeHandoffCandidate,
DomainRuntimeHandoffSource,
} from './domain-access-orchestrator.js';
import type { DomainNodeType } from './domain-access.js';
import {
assertVerifiedNodePossession,
type NodePossessionChallenge,
type NodePossessionResponse,
type VerifiedNodePossession,
} from './node-possession-proof.js';
import type { VerifiedNodeRegistration } from './node-registration.js';
import { randomUUID } from 'node:crypto';
const IDENTIFIER_PATTERN = /^[A-Z0-9][A-Z0-9._:-]{1,159}$/;
const REQUEST_SCHEMA = 'gh-aios.domain-runtime-handoff-request/v1' as const;
const RESPONSE_SCHEMA = 'gh-aios.domain-runtime-handoff-response/v1' as const;
const consumedPossessions = new WeakSet<object>();
export interface DomainRuntimeHandoffRequest {
accountId: string;
challenge: Readonly<NodePossessionChallenge>;
domainId: string;
issuedAt: number;
nodeId: string;
nodeType: DomainNodeType;
nodeType: VerifiedNodeRegistration['nodeType'];
registration: Readonly<VerifiedNodeRegistration>;
requestId: string;
response: Readonly<NodePossessionResponse>;
schema: typeof REQUEST_SCHEMA;
}
export interface DomainRuntimeHandoffTransport {
@ -26,19 +43,28 @@ function hasExactKeys(value: Record<string, unknown>, expected: readonly string[
return actual.length === keys.length && actual.every((key, index) => key === keys[index]);
}
function parseResponse(input: unknown): Readonly<DomainRuntimeHandoffCandidate> | null {
function parseResponse(
input: unknown,
request: DomainRuntimeHandoffRequest,
): Readonly<DomainRuntimeHandoffCandidate> | null {
if (!isRecord(input)
|| !hasExactKeys(input, ['handoff', 'schema', 'signer_lookup'])
|| input.schema !== 'gh-aios.domain-runtime-handoff-response/v1'
|| !isRecord(input.handoff)
|| !hasExactKeys(input, ['handoff', 'request_id', 'schema', 'signer_lookup', 'status'])
|| input.schema !== RESPONSE_SCHEMA
|| input.request_id !== request.requestId
|| (input.status !== 'ISSUED' && input.status !== 'NOT_AUTHORIZED')) {
return null;
}
if (input.status === 'NOT_AUTHORIZED') {
if (input.handoff !== null || input.signer_lookup !== null) return null;
return null;
}
if (!isRecord(input.handoff)
|| !isRecord(input.signer_lookup)
|| !hasExactKeys(input.signer_lookup, ['repository_id', 'signer_id'])
|| typeof input.signer_lookup.repository_id !== 'string'
|| !IDENTIFIER_PATTERN.test(input.signer_lookup.repository_id)
|| typeof input.signer_lookup.signer_id !== 'string'
|| !IDENTIFIER_PATTERN.test(input.signer_lookup.signer_id)) {
return null;
}
|| !IDENTIFIER_PATTERN.test(input.signer_lookup.signer_id)) return null;
return Object.freeze({
handoff: input.handoff,
signerLookup: Object.freeze({
@ -49,13 +75,37 @@ function parseResponse(input: unknown): Readonly<DomainRuntimeHandoffCandidate>
}
export class StrictDomainRuntimeHandoffSource implements DomainRuntimeHandoffSource {
constructor(private readonly transport: DomainRuntimeHandoffTransport) {}
constructor(
private readonly transport: DomainRuntimeHandoffTransport,
private readonly clock: () => number = Date.now,
private readonly requestId: () => string = () => `HANDOFF-REQ-${randomUUID().toUpperCase()}`,
) {}
async read(domainId: string, nodeId: string, nodeType: DomainNodeType): Promise<Readonly<DomainRuntimeHandoffCandidate> | null> {
if (!IDENTIFIER_PATTERN.test(domainId) || !IDENTIFIER_PATTERN.test(nodeId)) return null;
if (nodeType !== 'local-terminal' && nodeType !== 'cloud-resident') return null;
async read(possession: VerifiedNodePossession): Promise<Readonly<DomainRuntimeHandoffCandidate> | null> {
try {
return parseResponse(await this.transport.request(Object.freeze({ domainId, nodeId, nodeType })));
const verified = assertVerifiedNodePossession(possession);
if (consumedPossessions.has(verified)) return null;
const issuedAt = this.clock();
const requestId = this.requestId();
if (!Number.isSafeInteger(issuedAt)
|| issuedAt < verified.verifiedAt
|| issuedAt >= verified.challenge.expiresAt
|| issuedAt >= verified.registration.expiresAt
|| !IDENTIFIER_PATTERN.test(requestId)) return null;
const request = Object.freeze({
accountId: verified.registration.accountId,
challenge: verified.challenge,
domainId: verified.registration.domainId,
issuedAt,
nodeId: verified.registration.nodeId,
nodeType: verified.registration.nodeType,
registration: verified.registration,
requestId,
response: verified.response,
schema: REQUEST_SCHEMA,
});
consumedPossessions.add(verified);
return parseResponse(await this.transport.request(request), request);
} catch {
return null;
}

View file

@ -91,7 +91,10 @@ test('verifies a fresh proof from the key fingerprint bound into the signed regi
'A'.repeat(43),
);
const response = responseFor(challenge);
assert.equal(verifyNodePossessionResponse(response, challenge, registration, NOW + 1), true);
const possession = verifyNodePossessionResponse(response, challenge, registration, NOW + 1);
assert.equal(possession.registration, registration);
assert.equal(possession.challenge, challenge);
assert.deepEqual(possession.response, response);
assert.throws(() => verifyNodePossessionResponse(response, challenge, registration, NOW + 2), /proof_invalid/);
});

View file

@ -17,6 +17,7 @@ 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>();
const verifiedPossessions = new WeakSet<object>();
export interface NodePossessionChallenge {
accountId: string;
@ -43,6 +44,13 @@ export interface NodePossessionProofSource {
prove(challenge: Readonly<NodePossessionChallenge>): Promise<unknown | null>;
}
export interface VerifiedNodePossession {
challenge: Readonly<NodePossessionChallenge>;
registration: Readonly<VerifiedNodeRegistration>;
response: Readonly<NodePossessionResponse>;
verifiedAt: number;
}
function invalidProof(): never {
throw new Error('node_possession_proof_invalid');
}
@ -113,7 +121,7 @@ export function verifyNodePossessionResponse(
challenge: NodePossessionChallenge,
registration: VerifiedNodeRegistration,
now = Date.now(),
): true {
): Readonly<VerifiedNodePossession> {
try {
assertVerifiedNodeRegistration(registration);
} catch {
@ -140,7 +148,11 @@ export function verifyNodePossessionResponse(
|| input.publicKeyPem.includes('PRIVATE KEY')
|| typeof input.signature !== 'string') invalidProof();
let publicKeyPem: string;
let signatureText: string;
try {
publicKeyPem = input.publicKeyPem as string;
signatureText = input.signature as string;
const publicKey = createPublicKey({ key: input.publicKeyPem, format: 'pem' });
if (publicKey.asymmetricKeyType !== 'ed25519') invalidProof();
const fingerprint = createHash('sha256')
@ -154,5 +166,24 @@ export function verifyNodePossessionResponse(
invalidProof();
}
consumedChallenges.add(challenge);
return true;
const verified = Object.freeze({
challenge,
registration,
response: Object.freeze({
challengeId: challenge.challengeId,
publicKeyPem: publicKeyPem!,
schema: RESPONSE_SCHEMA,
signature: signatureText!,
}),
verifiedAt: now,
});
verifiedPossessions.add(verified);
return verified;
}
export function assertVerifiedNodePossession(
possession: VerifiedNodePossession,
): VerifiedNodePossession {
if (!verifiedPossessions.has(possession)) invalidProof();
return possession;
}

View file

@ -79,3 +79,23 @@
运行体交接。当前桌面尚无生产节点密钥库桥,正式运行源固定返回无证明,因此即使未来出现一份
有效登记声明,也不能越过 `verified_node_possession_missing`。代码验证器存在不等于节点密钥、
生产密钥库桥、灯塔会话签发或域连接已经存在。
## 私钥证明到域交接的证据链 · 阶段 004
本地布尔值 `nodePossessionVerified=true` 不能作为灯塔签发域会话的请求证据。节点证明验证器
现在生成进程内不可伪造的 `VerifiedNodePossession`:包含已验证登记声明、一次性挑战、节点
签名响应和验证时间,并通过内部来源集合阻止调用方复制字段冒充验证结果。
域运行体交接源只接受这个验证对象,并构造固定
`gh-aios.domain-runtime-handoff-request/v1` 请求包:账号、域、节点编号、节点类型、完整签名
登记声明、挑战、节点响应、一次性请求编号和请求时间。它不包含账号密码、访问令牌、验证码
或节点私钥。每份节点证明最多发起一次交接请求;失败重试必须重新完成新挑战,不能重放旧证明。
响应固定为 `gh-aios.domain-runtime-handoff-response/v1`,并必须回绑请求编号。只允许
`ISSUED + handoff + signer_lookup``NOT_AUTHORIZED + null + null`。客户端仍须使用当前
REPO-012 信任快照独立验证 handoff 中的域清单签名、短期会话能力和在线连接回执;服务端
返回的 signer 名称、公钥字段或自授权布尔值都不能成为信任来源。
本阶段闭合的是“证明能够被带到交接边界”的客户端证据结构,不是生产 HTTP 端点。当前公共
锚点没有域交接端点注册表,桌面运行源仍返回空结果,因此生产会话签发、域运行体连接与在线
回执继续为 `NOT_EXISTS_0`

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)账号、签名节点登记与当前节点私钥持有证明分离后的端点注册表、无凭据声明读取、60 秒一次性 Ed25519 挑战、失败关闭和当前生产缺口验收基线。
- [`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 历史意图层,继续保存形成过程;不再作为当前默认入口。