Implement fail-closed node keystore bridge
This commit is contained in:
parent
c0e3c67fe4
commit
b4d6e7a4f9
5 changed files with 345 additions and 5 deletions
|
|
@ -0,0 +1,131 @@
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { createHash, generateKeyPairSync, sign } from 'node:crypto';
|
||||||
|
import { EncryptedNodeKeyStoreProofSource, type NodeKeyStoreAdapter } from './node-keystore-bridge.js';
|
||||||
|
import { createNodePossessionChallenge, 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 nodePrivateKeyPkcs8 = nodeKeys.privateKey.export({ format: 'der', type: 'pkcs8' });
|
||||||
|
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 keyRecord(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
encryptedPrivateKeyPkcs8: Buffer.from('encrypted-test-record').toString('base64'),
|
||||||
|
nodeId: NODE_ID,
|
||||||
|
nodeKeyFingerprint: fingerprint,
|
||||||
|
nodeType: 'local-terminal',
|
||||||
|
publicKeyPem: nodePublicKeyPem,
|
||||||
|
schema: 'gh-aios.node-keystore-record/v1',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function adapter(overrides: Partial<NodeKeyStoreAdapter> = {}): NodeKeyStoreAdapter {
|
||||||
|
return {
|
||||||
|
async decryptPrivateKeyPkcs8() { return Buffer.from(nodePrivateKeyPkcs8); },
|
||||||
|
isAvailable() { return true; },
|
||||||
|
async readRecord() { return keyRecord(); },
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function challenge(id = 'NODE-CHALLENGE-KEYSTORE-001') {
|
||||||
|
return createNodePossessionChallenge(verifiedRegistration(), NOW, id, 'A'.repeat(43));
|
||||||
|
}
|
||||||
|
|
||||||
|
test('signs only an active challenge with the enrolled matching Ed25519 key', async () => {
|
||||||
|
const issued = challenge();
|
||||||
|
const source = new EncryptedNodeKeyStoreProofSource(adapter(), () => NOW + 1);
|
||||||
|
const response = await source.prove(issued);
|
||||||
|
assert.ok(response);
|
||||||
|
assert.deepEqual(Object.keys(response).sort(), ['challengeId', 'publicKeyPem', 'schema', 'signature']);
|
||||||
|
assert.ok(verifyNodePossessionResponse(response, issued, verifiedRegistration(), NOW + 2));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns no proof when storage is unavailable or no enrollment exists', async () => {
|
||||||
|
const issued = challenge('NODE-CHALLENGE-KEYSTORE-002');
|
||||||
|
assert.equal(await new EncryptedNodeKeyStoreProofSource(adapter({ isAvailable: () => false }), () => NOW + 1).prove(issued), null);
|
||||||
|
assert.equal(await new EncryptedNodeKeyStoreProofSource(adapter({ readRecord: async () => null }), () => NOW + 1).prove(issued), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects copied challenges, unknown record fields and node binding mismatches', async () => {
|
||||||
|
const issued = challenge('NODE-CHALLENGE-KEYSTORE-003');
|
||||||
|
assert.equal(await new EncryptedNodeKeyStoreProofSource(adapter(), () => NOW + 1).prove({ ...issued }), null);
|
||||||
|
assert.equal(await new EncryptedNodeKeyStoreProofSource(adapter({ readRecord: async () => keyRecord({ extra: true }) }), () => NOW + 1).prove(issued), null);
|
||||||
|
assert.equal(await new EncryptedNodeKeyStoreProofSource(adapter({ readRecord: async () => keyRecord({ nodeId: 'OTHER-NODE' }) }), () => NOW + 1).prove(issued), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects malformed encryption payloads and mismatched private keys', async () => {
|
||||||
|
const issued = challenge('NODE-CHALLENGE-KEYSTORE-004');
|
||||||
|
assert.equal(await new EncryptedNodeKeyStoreProofSource(adapter({ readRecord: async () => keyRecord({ encryptedPrivateKeyPkcs8: '***' }) }), () => NOW + 1).prove(issued), null);
|
||||||
|
const other = generateKeyPairSync('ed25519').privateKey.export({ format: 'der', type: 'pkcs8' });
|
||||||
|
assert.equal(await new EncryptedNodeKeyStoreProofSource(adapter({ decryptPrivateKeyPkcs8: async () => Buffer.from(other) }), () => NOW + 1).prove(issued), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('wipes the decrypted temporary key bytes after signing', async () => {
|
||||||
|
const issued = challenge('NODE-CHALLENGE-KEYSTORE-005');
|
||||||
|
const decrypted = Buffer.from(nodePrivateKeyPkcs8);
|
||||||
|
const source = new EncryptedNodeKeyStoreProofSource(adapter({ decryptPrivateKeyPkcs8: async () => decrypted }), () => NOW + 1);
|
||||||
|
assert.ok(await source.prove(issued));
|
||||||
|
assert.ok(decrypted.every(byte => byte === 0));
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,124 @@
|
||||||
|
import {
|
||||||
|
createHash,
|
||||||
|
createPrivateKey,
|
||||||
|
createPublicKey,
|
||||||
|
sign,
|
||||||
|
} from 'node:crypto';
|
||||||
|
import {
|
||||||
|
assertActiveNodePossessionChallenge,
|
||||||
|
nodePossessionChallengeSigningBytes,
|
||||||
|
type NodePossessionChallenge,
|
||||||
|
type NodePossessionProofSource,
|
||||||
|
type NodePossessionResponse,
|
||||||
|
} from './node-possession-proof.js';
|
||||||
|
|
||||||
|
const RECORD_SCHEMA = 'gh-aios.node-keystore-record/v1' as const;
|
||||||
|
const RESPONSE_SCHEMA = 'gh-aios.node-possession-response/v1' as const;
|
||||||
|
const IDENTIFIER_PATTERN = /^[A-Z0-9][A-Z0-9._:-]{1,159}$/;
|
||||||
|
const FINGERPRINT_PATTERN = /^[a-f0-9]{64}$/;
|
||||||
|
const BASE64_PATTERN = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
|
||||||
|
|
||||||
|
interface NodeKeyRecord {
|
||||||
|
encryptedPrivateKeyPkcs8: string;
|
||||||
|
nodeId: string;
|
||||||
|
nodeKeyFingerprint: string;
|
||||||
|
nodeType: 'local-terminal' | 'cloud-resident';
|
||||||
|
publicKeyPem: string;
|
||||||
|
schema: typeof RECORD_SCHEMA;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NodeKeyStoreAdapter {
|
||||||
|
decryptPrivateKeyPkcs8(encrypted: string): Promise<Buffer | null>;
|
||||||
|
isAvailable(): boolean;
|
||||||
|
readRecord(): Promise<unknown | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRecord(input: unknown): NodeKeyRecord | null {
|
||||||
|
if (!isRecord(input)
|
||||||
|
|| !hasExactKeys(input, [
|
||||||
|
'encryptedPrivateKeyPkcs8',
|
||||||
|
'nodeId',
|
||||||
|
'nodeKeyFingerprint',
|
||||||
|
'nodeType',
|
||||||
|
'publicKeyPem',
|
||||||
|
'schema',
|
||||||
|
])
|
||||||
|
|| input.schema !== RECORD_SCHEMA
|
||||||
|
|| typeof input.nodeId !== 'string'
|
||||||
|
|| !IDENTIFIER_PATTERN.test(input.nodeId)
|
||||||
|
|| (input.nodeType !== 'local-terminal' && input.nodeType !== 'cloud-resident')
|
||||||
|
|| typeof input.nodeKeyFingerprint !== 'string'
|
||||||
|
|| !FINGERPRINT_PATTERN.test(input.nodeKeyFingerprint)
|
||||||
|
|| typeof input.publicKeyPem !== 'string'
|
||||||
|
|| input.publicKeyPem.length > 4096
|
||||||
|
|| input.publicKeyPem.includes('PRIVATE KEY')
|
||||||
|
|| typeof input.encryptedPrivateKeyPkcs8 !== 'string'
|
||||||
|
|| input.encryptedPrivateKeyPkcs8.length < 4
|
||||||
|
|| input.encryptedPrivateKeyPkcs8.length > 65_536
|
||||||
|
|| !BASE64_PATTERN.test(input.encryptedPrivateKeyPkcs8)) return null;
|
||||||
|
return input as unknown as NodeKeyRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
function publicKeyFingerprint(publicKeyPem: string): string | null {
|
||||||
|
try {
|
||||||
|
const publicKey = createPublicKey({ key: publicKeyPem, format: 'pem' });
|
||||||
|
if (publicKey.asymmetricKeyType !== 'ed25519') return null;
|
||||||
|
return createHash('sha256')
|
||||||
|
.update(publicKey.export({ format: 'der', type: 'spki' }))
|
||||||
|
.digest('hex');
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class EncryptedNodeKeyStoreProofSource implements NodePossessionProofSource {
|
||||||
|
constructor(
|
||||||
|
private readonly adapter: NodeKeyStoreAdapter,
|
||||||
|
private readonly clock: () => number = Date.now,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async prove(challenge: Readonly<NodePossessionChallenge>): Promise<Readonly<NodePossessionResponse> | null> {
|
||||||
|
let privateKeyPkcs8: Buffer | null = null;
|
||||||
|
try {
|
||||||
|
assertActiveNodePossessionChallenge(challenge, this.clock());
|
||||||
|
if (!this.adapter.isAvailable()) return null;
|
||||||
|
const record = parseRecord(await this.adapter.readRecord());
|
||||||
|
if (!record
|
||||||
|
|| record.nodeId !== challenge.nodeId
|
||||||
|
|| record.nodeType !== challenge.nodeType
|
||||||
|
|| record.nodeKeyFingerprint !== challenge.nodeKeyFingerprint
|
||||||
|
|| publicKeyFingerprint(record.publicKeyPem) !== record.nodeKeyFingerprint) return null;
|
||||||
|
|
||||||
|
privateKeyPkcs8 = await this.adapter.decryptPrivateKeyPkcs8(record.encryptedPrivateKeyPkcs8);
|
||||||
|
if (!privateKeyPkcs8 || privateKeyPkcs8.length === 0 || privateKeyPkcs8.length > 16_384) return null;
|
||||||
|
const privateKey = createPrivateKey({ key: privateKeyPkcs8, format: 'der', type: 'pkcs8' });
|
||||||
|
if (privateKey.asymmetricKeyType !== 'ed25519') return null;
|
||||||
|
const derivedPublicKey = createPublicKey(privateKey);
|
||||||
|
const derivedFingerprint = createHash('sha256')
|
||||||
|
.update(derivedPublicKey.export({ format: 'der', type: 'spki' }))
|
||||||
|
.digest('hex');
|
||||||
|
if (derivedFingerprint !== record.nodeKeyFingerprint) return null;
|
||||||
|
|
||||||
|
return Object.freeze({
|
||||||
|
challengeId: challenge.challengeId,
|
||||||
|
publicKeyPem: record.publicKeyPem,
|
||||||
|
schema: RESPONSE_SCHEMA,
|
||||||
|
signature: sign(null, nodePossessionChallengeSigningBytes(challenge), privateKey).toString('base64'),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
privateKeyPkcs8?.fill(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -81,6 +81,18 @@ export function nodePossessionChallengeSigningBytes(challenge: NodePossessionCha
|
||||||
}), 'utf8');
|
}), 'utf8');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function assertActiveNodePossessionChallenge(
|
||||||
|
challenge: NodePossessionChallenge,
|
||||||
|
now = Date.now(),
|
||||||
|
): NodePossessionChallenge {
|
||||||
|
if (!issuedChallenges.has(challenge)
|
||||||
|
|| consumedChallenges.has(challenge)
|
||||||
|
|| !Number.isSafeInteger(now)
|
||||||
|
|| now < challenge.issuedAt
|
||||||
|
|| now >= challenge.expiresAt) invalidProof();
|
||||||
|
return challenge;
|
||||||
|
}
|
||||||
|
|
||||||
export function createNodePossessionChallenge(
|
export function createNodePossessionChallenge(
|
||||||
registration: VerifiedNodeRegistration,
|
registration: VerifiedNodeRegistration,
|
||||||
now = Date.now(),
|
now = Date.now(),
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import { DomainAccessOrchestrator } from '../../guanghu-knowledge-base/server/do
|
||||||
import { TrustedSignerSnapshotLoader } from '../../guanghu-knowledge-base/server/trusted-signer-snapshot.js';
|
import { TrustedSignerSnapshotLoader } from '../../guanghu-knowledge-base/server/trusted-signer-snapshot.js';
|
||||||
import { NodeRegistrationSnapshotLoader } from '../../guanghu-knowledge-base/server/node-registration-snapshot.js';
|
import { NodeRegistrationSnapshotLoader } from '../../guanghu-knowledge-base/server/node-registration-snapshot.js';
|
||||||
import { HttpNodeRegistrationClaimSource } from '../../guanghu-knowledge-base/server/node-registration-client.js';
|
import { HttpNodeRegistrationClaimSource } from '../../guanghu-knowledge-base/server/node-registration-client.js';
|
||||||
|
import { EncryptedNodeKeyStoreProofSource } from '../../guanghu-knowledge-base/server/node-keystore-bridge.js';
|
||||||
import { DomainRuntimeHandoffSnapshotLoader } from '../../guanghu-knowledge-base/server/domain-runtime-handoff-snapshot.js';
|
import { DomainRuntimeHandoffSnapshotLoader } from '../../guanghu-knowledge-base/server/domain-runtime-handoff-snapshot.js';
|
||||||
import {
|
import {
|
||||||
AnchoredDomainRuntimeHandoffSource,
|
AnchoredDomainRuntimeHandoffSource,
|
||||||
|
|
@ -40,6 +41,7 @@ const KB_REPO_PATH = path.join(DATA_DIR, 'knowledge-base');
|
||||||
const MODEL_CONFIG_PATH = path.join(app.getPath('userData'), 'model-config.json');
|
const MODEL_CONFIG_PATH = path.join(app.getPath('userData'), 'model-config.json');
|
||||||
const SERVER_AUTH_PATH = path.join(app.getPath('userData'), 'server-auth.json');
|
const SERVER_AUTH_PATH = path.join(app.getPath('userData'), 'server-auth.json');
|
||||||
const SERVER_PROFILES_PATH = path.join(app.getPath('userData'), 'server-profiles.json');
|
const SERVER_PROFILES_PATH = path.join(app.getPath('userData'), 'server-profiles.json');
|
||||||
|
const NODE_KEYSTORE_PATH = path.join(app.getPath('userData'), 'node-keystore.json');
|
||||||
const AGENT_STATE_PATH = path.join(app.getPath('userData'), 'agent-conversations.json');
|
const AGENT_STATE_PATH = path.join(app.getPath('userData'), 'agent-conversations.json');
|
||||||
const GIT_ASKPASS_PATH = path.join(app.getPath('userData'), 'hololake-git-askpass.sh');
|
const GIT_ASKPASS_PATH = path.join(app.getPath('userData'), 'hololake-git-askpass.sh');
|
||||||
interface ServerProfileDefinition {
|
interface ServerProfileDefinition {
|
||||||
|
|
@ -366,6 +368,37 @@ const domainRuntimeHandoffs = new AnchoredDomainRuntimeHandoffSource(
|
||||||
new HttpDomainRuntimeHandoffTransport(),
|
new HttpDomainRuntimeHandoffTransport(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const nodeKeyStoreProofs = new EncryptedNodeKeyStoreProofSource({
|
||||||
|
isAvailable() {
|
||||||
|
return safeStorage.isEncryptionAvailable();
|
||||||
|
},
|
||||||
|
async readRecord() {
|
||||||
|
try {
|
||||||
|
const stat = fs.statSync(NODE_KEYSTORE_PATH);
|
||||||
|
if (!stat.isFile() || (stat.mode & 0o077) !== 0) return null;
|
||||||
|
return JSON.parse(fs.readFileSync(NODE_KEYSTORE_PATH, 'utf8')) as unknown;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async decryptPrivateKeyPkcs8(encrypted) {
|
||||||
|
try {
|
||||||
|
if (!safeStorage.isEncryptionAvailable()) return null;
|
||||||
|
const ciphertext = Buffer.from(encrypted, 'base64');
|
||||||
|
if (ciphertext.length === 0 || ciphertext.toString('base64') !== encrypted) return null;
|
||||||
|
const plaintextBase64 = safeStorage.decryptString(ciphertext);
|
||||||
|
const plaintext = Buffer.from(plaintextBase64, 'base64');
|
||||||
|
if (plaintext.length === 0 || plaintext.toString('base64') !== plaintextBase64) {
|
||||||
|
plaintext.fill(0);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return plaintext;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const domainAccessOrchestrator = new DomainAccessOrchestrator(
|
const domainAccessOrchestrator = new DomainAccessOrchestrator(
|
||||||
trustedSignerSnapshots,
|
trustedSignerSnapshots,
|
||||||
nodeRegistrationSnapshots,
|
nodeRegistrationSnapshots,
|
||||||
|
|
@ -388,11 +421,7 @@ const domainAccessOrchestrator = new DomainAccessOrchestrator(
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
nodeRegistrationClaims,
|
nodeRegistrationClaims,
|
||||||
{
|
nodeKeyStoreProofs,
|
||||||
// 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; },
|
|
||||||
},
|
|
||||||
domainRuntimeHandoffs,
|
domainRuntimeHandoffs,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
# HoloLake 节点密钥库桥契约 · 2026-08-10
|
||||||
|
|
||||||
|
> 阶段:`DEV-20260809-007 / NODE-KEYSTORE-BRIDGE-001`
|
||||||
|
>
|
||||||
|
> 上游:`HOLOLAKE-NODE-REGISTRATION-CONTRACT-20260810` 阶段 003
|
||||||
|
>
|
||||||
|
> 状态:`IMPLEMENTATION_STAGE_ACCEPTANCE_BASELINE`
|
||||||
|
|
||||||
|
## 验收标准
|
||||||
|
|
||||||
|
1. 正式域登录只读取本机明确登记的节点密钥记录;记录缺失、加密存储不可用、解密失败、
|
||||||
|
字段不完整或任一绑定不一致时返回无证明,禁止自动生成或自动登记生产节点身份。
|
||||||
|
2. 节点密钥记录必须精确绑定 `nodeId`、`nodeType`、Ed25519 公钥与其 SPKI DER SHA-256
|
||||||
|
指纹;挑战中的节点编号、类型和指纹必须与记录完全一致。
|
||||||
|
3. 私钥以 PKCS#8 DER 形式进入签名边界,只允许 Ed25519;实现必须从私钥重新导出公钥并
|
||||||
|
核对登记指纹,签名结束后立即覆写临时私钥字节。
|
||||||
|
4. 桥对外只能返回 `gh-aios.node-possession-response/v1` 的挑战编号、公钥和签名;不得返回
|
||||||
|
私钥、解密结果、加密载荷、密钥路径或底层错误细节。
|
||||||
|
5. 未知字段、复制的挑战对象、过期挑战、节点绑定错误、公私钥不配对、无效 Base64、错误
|
||||||
|
密钥类型及签名异常全部失败关闭为 `null`,不得抛出秘密相关错误进入产品投影。
|
||||||
|
6. Electron 适配层只在密钥记录已经存在时读取;当前阶段不得创建真实生产密钥文件、不得
|
||||||
|
发起钥匙串或隐私确认,也不得把测试密钥安装到桌面数据目录。
|
||||||
|
7. 当前正式数据目录没有已登记节点密钥记录,因此本阶段完成后的生产判断仍为
|
||||||
|
`NODE_KEYSTORE_BRIDGE_NOT_ENROLLED_0`;代码可运行不等于生产节点身份、灯塔登记、域会话或
|
||||||
|
运行体连接已经存在。
|
||||||
|
|
||||||
|
## 本地记录边界
|
||||||
|
|
||||||
|
记录 schema 固定为 `gh-aios.node-keystore-record/v1`,只允许:
|
||||||
|
|
||||||
|
- `nodeId`、`nodeType`;
|
||||||
|
- `nodeKeyFingerprint`、`publicKeyPem`;
|
||||||
|
- `encryptedPrivateKeyPkcs8`;
|
||||||
|
- `schema`。
|
||||||
|
|
||||||
|
加密私钥字段由宿主安全存储保护,明文是严格 Base64 的 PKCS#8 DER。记录文件本身必须以
|
||||||
|
`0600` 权限保存,但本阶段只实现已有记录的读取与证明,不实现登记写入。
|
||||||
|
|
||||||
|
## 当前现实边界
|
||||||
|
|
||||||
|
- 当前公共锚点仍没有节点登记端点与域运行体交接端点映射;
|
||||||
|
- 当前桌面没有本阶段 schema 的生产节点密钥记录;
|
||||||
|
- 当前没有生产节点登记声明、节点证明、域会话能力或在线连接回执;
|
||||||
|
- 因而域入口继续停留在登录/登记门,这是正确的失败关闭结果。
|
||||||
Loading…
Reference in a new issue