Bind runtime handoff to node proof
This commit is contained in:
parent
ca67ba92d2
commit
46d19aeb45
7 changed files with 249 additions and 55 deletions
|
|
@ -23,6 +23,7 @@ import {
|
||||||
createNodePossessionChallenge,
|
createNodePossessionChallenge,
|
||||||
verifyNodePossessionResponse,
|
verifyNodePossessionResponse,
|
||||||
type NodePossessionProofSource,
|
type NodePossessionProofSource,
|
||||||
|
type VerifiedNodePossession,
|
||||||
} from './node-possession-proof.js';
|
} from './node-possession-proof.js';
|
||||||
|
|
||||||
const IDENTIFIER_PATTERN = /^[A-Z0-9][A-Z0-9._:-]{1,159}$/;
|
const IDENTIFIER_PATTERN = /^[A-Z0-9][A-Z0-9._:-]{1,159}$/;
|
||||||
|
|
@ -46,7 +47,7 @@ export interface DomainRuntimeHandoffCandidate {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DomainRuntimeHandoffSource {
|
export interface DomainRuntimeHandoffSource {
|
||||||
read(domainId: string, nodeId: string, nodeType: DomainNodeType): Promise<DomainRuntimeHandoffCandidate | null>;
|
read(possession: VerifiedNodePossession): Promise<DomainRuntimeHandoffCandidate | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DomainAccessProjection {
|
export interface DomainAccessProjection {
|
||||||
|
|
@ -102,6 +103,7 @@ export class DomainAccessOrchestrator {
|
||||||
]);
|
]);
|
||||||
let nodeRegistrationVerified = false;
|
let nodeRegistrationVerified = false;
|
||||||
let nodePossessionVerified = false;
|
let nodePossessionVerified = false;
|
||||||
|
let possession: VerifiedNodePossession | null = null;
|
||||||
if (identity.accountVerified && nodeRegistrationSnapshot.receipt.status === 'CURRENT') {
|
if (identity.accountVerified && nodeRegistrationSnapshot.receipt.status === 'CURRENT') {
|
||||||
try {
|
try {
|
||||||
const loaded = assertLoadedNodeRegistrationSnapshot(nodeRegistrationSnapshot);
|
const loaded = assertLoadedNodeRegistrationSnapshot(nodeRegistrationSnapshot);
|
||||||
|
|
@ -126,7 +128,7 @@ export class DomainAccessOrchestrator {
|
||||||
const response = await this.nodePossessions.prove(challenge);
|
const response = await this.nodePossessions.prove(challenge);
|
||||||
if (response) {
|
if (response) {
|
||||||
if (at === undefined) now = this.clock();
|
if (at === undefined) now = this.clock();
|
||||||
verifyNodePossessionResponse(response, challenge, registration, now);
|
possession = verifyNodePossessionResponse(response, challenge, registration, now);
|
||||||
nodePossessionVerified = true;
|
nodePossessionVerified = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -144,10 +146,10 @@ export class DomainAccessOrchestrator {
|
||||||
nodeType,
|
nodeType,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (identity.accountVerified && nodeRegistrationVerified && nodePossessionVerified) {
|
if (identity.accountVerified && nodeRegistrationVerified && nodePossessionVerified && possession) {
|
||||||
let candidate: DomainRuntimeHandoffCandidate | null = null;
|
let candidate: DomainRuntimeHandoffCandidate | null = null;
|
||||||
try {
|
try {
|
||||||
candidate = await this.handoffs.read(domainId, identity.nodeId, nodeType);
|
candidate = await this.handoffs.read(possession);
|
||||||
} catch {
|
} catch {
|
||||||
candidate = null;
|
candidate = null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,75 +1,163 @@
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
import { createHash, generateKeyPairSync, sign } from 'node:crypto';
|
||||||
import { StrictDomainRuntimeHandoffSource } from './domain-runtime-handoff-source.js';
|
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 DOMAIN_ID = 'DOM-FIFTH-0001';
|
||||||
const NODE_ID = 'JD-FD-PRIMARY';
|
const NODE_ID = 'JD-FD-PRIMARY';
|
||||||
const NODE_TYPE = 'cloud-resident' as const;
|
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 {
|
return {
|
||||||
schema: 'gh-aios.domain-runtime-handoff-response/v1',
|
schema: 'gh-aios.domain-runtime-handoff-response/v1',
|
||||||
handoff: { manifest: { digest: 'untrusted-until-orchestrator-verifies' } },
|
handoff: { manifest: { digest: 'untrusted-until-orchestrator-verifies' } },
|
||||||
|
request_id: requestId,
|
||||||
signer_lookup: {
|
signer_lookup: {
|
||||||
repository_id: 'REPO-014',
|
repository_id: 'REPO-014',
|
||||||
signer_id: 'GH-LIGHTHOUSE-001',
|
signer_id: 'GH-LIGHTHOUSE-001',
|
||||||
},
|
},
|
||||||
|
status: 'ISSUED',
|
||||||
...overrides,
|
...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 requests: unknown[] = [];
|
||||||
const source = new StrictDomainRuntimeHandoffSource({
|
const source = new StrictDomainRuntimeHandoffSource({
|
||||||
async request(input) {
|
async request(input) {
|
||||||
requests.push(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, {
|
assert.deepEqual(candidate, {
|
||||||
handoff: { manifest: { digest: 'untrusted-until-orchestrator-verifies' } },
|
handoff: { manifest: { digest: 'untrusted-until-orchestrator-verifies' } },
|
||||||
signerLookup: { repositoryId: 'REPO-014', signerId: 'GH-LIGHTHOUSE-001' },
|
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 = [
|
const samples = [
|
||||||
null,
|
null,
|
||||||
response({ extra: true }),
|
response('OTHER-REQ'),
|
||||||
response({ schema: 'other/v1' }),
|
response('HANDOFF-REQ-001', { extra: true }),
|
||||||
response({ signer_lookup: { repository_id: 'REPO-014', signer_id: 'bad signer' } }),
|
response('HANDOFF-REQ-001', { schema: 'other/v1' }),
|
||||||
response({ signer_lookup: { repository_id: 'REPO-014', signer_id: 'GH-LIGHTHOUSE-001', public_key: 'forbidden' } }),
|
response('HANDOFF-REQ-001', { signer_lookup: { repository_id: 'REPO-014', signer_id: 'bad signer' } }),
|
||||||
response({ handoff: null }),
|
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;
|
for (const sample of samples) {
|
||||||
const source = new StrictDomainRuntimeHandoffSource({
|
const source = new StrictDomainRuntimeHandoffSource({ async request() { return sample; } }, () => NOW + 2, () => 'HANDOFF-REQ-001');
|
||||||
async request() { return samples[index++]; },
|
assert.equal(await source.read(verifiedPossession()), null);
|
||||||
});
|
|
||||||
|
|
||||||
for (const _sample of samples) {
|
|
||||||
assert.equal(await source.read(DOMAIN_ID, NODE_ID, NODE_TYPE), 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;
|
let requests = 0;
|
||||||
|
const possession = verifiedPossession();
|
||||||
const source = new StrictDomainRuntimeHandoffSource({
|
const source = new StrictDomainRuntimeHandoffSource({
|
||||||
async request() {
|
async request(input) { requests += 1; return response(input.requestId); },
|
||||||
requests += 1;
|
}, () => NOW + 2, () => 'HANDOFF-REQ-001');
|
||||||
throw new Error('secret remote detail');
|
assert.ok(await source.read(possession));
|
||||||
},
|
assert.equal(await source.read(possession), null);
|
||||||
});
|
assert.equal(await source.read({ ...verifiedPossession() }), null);
|
||||||
|
const expired = new StrictDomainRuntimeHandoffSource({
|
||||||
assert.equal(await source.read('bad domain', NODE_ID, NODE_TYPE), null);
|
async request(input) { requests += 1; return response(input.requestId); },
|
||||||
assert.equal(requests, 0);
|
}, () => NOW + 60_000, () => 'HANDOFF-REQ-002');
|
||||||
assert.equal(await source.read(DOMAIN_ID, NODE_ID, NODE_TYPE), null);
|
assert.equal(await expired.read(verifiedPossession()), null);
|
||||||
assert.equal(requests, 1);
|
|
||||||
assert.equal(await source.read(DOMAIN_ID, NODE_ID, 'invalid' as never), null);
|
|
||||||
assert.equal(requests, 1);
|
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);
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,31 @@ import type {
|
||||||
DomainRuntimeHandoffCandidate,
|
DomainRuntimeHandoffCandidate,
|
||||||
DomainRuntimeHandoffSource,
|
DomainRuntimeHandoffSource,
|
||||||
} from './domain-access-orchestrator.js';
|
} 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 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 {
|
export interface DomainRuntimeHandoffRequest {
|
||||||
|
accountId: string;
|
||||||
|
challenge: Readonly<NodePossessionChallenge>;
|
||||||
domainId: string;
|
domainId: string;
|
||||||
|
issuedAt: number;
|
||||||
nodeId: string;
|
nodeId: string;
|
||||||
nodeType: DomainNodeType;
|
nodeType: VerifiedNodeRegistration['nodeType'];
|
||||||
|
registration: Readonly<VerifiedNodeRegistration>;
|
||||||
|
requestId: string;
|
||||||
|
response: Readonly<NodePossessionResponse>;
|
||||||
|
schema: typeof REQUEST_SCHEMA;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DomainRuntimeHandoffTransport {
|
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]);
|
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)
|
if (!isRecord(input)
|
||||||
|| !hasExactKeys(input, ['handoff', 'schema', 'signer_lookup'])
|
|| !hasExactKeys(input, ['handoff', 'request_id', 'schema', 'signer_lookup', 'status'])
|
||||||
|| input.schema !== 'gh-aios.domain-runtime-handoff-response/v1'
|
|| input.schema !== RESPONSE_SCHEMA
|
||||||
|| !isRecord(input.handoff)
|
|| 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)
|
|| !isRecord(input.signer_lookup)
|
||||||
|| !hasExactKeys(input.signer_lookup, ['repository_id', 'signer_id'])
|
|| !hasExactKeys(input.signer_lookup, ['repository_id', 'signer_id'])
|
||||||
|| typeof input.signer_lookup.repository_id !== 'string'
|
|| typeof input.signer_lookup.repository_id !== 'string'
|
||||||
|| !IDENTIFIER_PATTERN.test(input.signer_lookup.repository_id)
|
|| !IDENTIFIER_PATTERN.test(input.signer_lookup.repository_id)
|
||||||
|| typeof input.signer_lookup.signer_id !== 'string'
|
|| typeof input.signer_lookup.signer_id !== 'string'
|
||||||
|| !IDENTIFIER_PATTERN.test(input.signer_lookup.signer_id)) {
|
|| !IDENTIFIER_PATTERN.test(input.signer_lookup.signer_id)) return null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
handoff: input.handoff,
|
handoff: input.handoff,
|
||||||
signerLookup: Object.freeze({
|
signerLookup: Object.freeze({
|
||||||
|
|
@ -49,13 +75,37 @@ function parseResponse(input: unknown): Readonly<DomainRuntimeHandoffCandidate>
|
||||||
}
|
}
|
||||||
|
|
||||||
export class StrictDomainRuntimeHandoffSource implements DomainRuntimeHandoffSource {
|
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> {
|
async read(possession: VerifiedNodePossession): Promise<Readonly<DomainRuntimeHandoffCandidate> | null> {
|
||||||
if (!IDENTIFIER_PATTERN.test(domainId) || !IDENTIFIER_PATTERN.test(nodeId)) return null;
|
|
||||||
if (nodeType !== 'local-terminal' && nodeType !== 'cloud-resident') return null;
|
|
||||||
try {
|
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 {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -91,7 +91,10 @@ test('verifies a fresh proof from the key fingerprint bound into the signed regi
|
||||||
'A'.repeat(43),
|
'A'.repeat(43),
|
||||||
);
|
);
|
||||||
const response = responseFor(challenge);
|
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/);
|
assert.throws(() => verifyNodePossessionResponse(response, challenge, registration, NOW + 2), /proof_invalid/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ const IDENTIFIER_PATTERN = /^[A-Z0-9][A-Z0-9._:-]{1,159}$/;
|
||||||
const NONCE_PATTERN = /^[A-Za-z0-9_-]{43}$/;
|
const NONCE_PATTERN = /^[A-Za-z0-9_-]{43}$/;
|
||||||
const issuedChallenges = new WeakSet<object>();
|
const issuedChallenges = new WeakSet<object>();
|
||||||
const consumedChallenges = new WeakSet<object>();
|
const consumedChallenges = new WeakSet<object>();
|
||||||
|
const verifiedPossessions = new WeakSet<object>();
|
||||||
|
|
||||||
export interface NodePossessionChallenge {
|
export interface NodePossessionChallenge {
|
||||||
accountId: string;
|
accountId: string;
|
||||||
|
|
@ -43,6 +44,13 @@ export interface NodePossessionProofSource {
|
||||||
prove(challenge: Readonly<NodePossessionChallenge>): Promise<unknown | null>;
|
prove(challenge: Readonly<NodePossessionChallenge>): Promise<unknown | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface VerifiedNodePossession {
|
||||||
|
challenge: Readonly<NodePossessionChallenge>;
|
||||||
|
registration: Readonly<VerifiedNodeRegistration>;
|
||||||
|
response: Readonly<NodePossessionResponse>;
|
||||||
|
verifiedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
function invalidProof(): never {
|
function invalidProof(): never {
|
||||||
throw new Error('node_possession_proof_invalid');
|
throw new Error('node_possession_proof_invalid');
|
||||||
}
|
}
|
||||||
|
|
@ -113,7 +121,7 @@ export function verifyNodePossessionResponse(
|
||||||
challenge: NodePossessionChallenge,
|
challenge: NodePossessionChallenge,
|
||||||
registration: VerifiedNodeRegistration,
|
registration: VerifiedNodeRegistration,
|
||||||
now = Date.now(),
|
now = Date.now(),
|
||||||
): true {
|
): Readonly<VerifiedNodePossession> {
|
||||||
try {
|
try {
|
||||||
assertVerifiedNodeRegistration(registration);
|
assertVerifiedNodeRegistration(registration);
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -140,7 +148,11 @@ export function verifyNodePossessionResponse(
|
||||||
|| input.publicKeyPem.includes('PRIVATE KEY')
|
|| input.publicKeyPem.includes('PRIVATE KEY')
|
||||||
|| typeof input.signature !== 'string') invalidProof();
|
|| typeof input.signature !== 'string') invalidProof();
|
||||||
|
|
||||||
|
let publicKeyPem: string;
|
||||||
|
let signatureText: string;
|
||||||
try {
|
try {
|
||||||
|
publicKeyPem = input.publicKeyPem as string;
|
||||||
|
signatureText = input.signature as string;
|
||||||
const publicKey = createPublicKey({ key: input.publicKeyPem, format: 'pem' });
|
const publicKey = createPublicKey({ key: input.publicKeyPem, format: 'pem' });
|
||||||
if (publicKey.asymmetricKeyType !== 'ed25519') invalidProof();
|
if (publicKey.asymmetricKeyType !== 'ed25519') invalidProof();
|
||||||
const fingerprint = createHash('sha256')
|
const fingerprint = createHash('sha256')
|
||||||
|
|
@ -154,5 +166,24 @@ export function verifyNodePossessionResponse(
|
||||||
invalidProof();
|
invalidProof();
|
||||||
}
|
}
|
||||||
consumedChallenges.add(challenge);
|
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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -79,3 +79,23 @@
|
||||||
运行体交接。当前桌面尚无生产节点密钥库桥,正式运行源固定返回无证明,因此即使未来出现一份
|
运行体交接。当前桌面尚无生产节点密钥库桥,正式运行源固定返回无证明,因此即使未来出现一份
|
||||||
有效登记声明,也不能越过 `verified_node_possession_missing`。代码验证器存在不等于节点密钥、
|
有效登记声明,也不能越过 `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`。
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
- [`HOLOLAKE-INTENT-REASONING-MAP-20260809.md`](HOLOLAKE-INTENT-REASONING-MAP-20260809.md):当前产品意图、纠正、思维推理与因果链。
|
- [`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-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-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-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 历史意图层,继续保存形成过程;不再作为当前默认入口。
|
- [`HOLOLAKE-INTENT-REASONING-MAP-20260807.md`](HOLOLAKE-INTENT-REASONING-MAP-20260807.md):2026-08-07 历史意图层,继续保存形成过程;不再作为当前默认入口。
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue