diff --git a/product-source/guanghu-knowledge-base/server/node-keystore-bridge.ts b/product-source/guanghu-knowledge-base/server/node-keystore-bridge.ts index 168f6a4..80b137a 100644 --- a/product-source/guanghu-knowledge-base/server/node-keystore-bridge.ts +++ b/product-source/guanghu-knowledge-base/server/node-keystore-bridge.ts @@ -12,19 +12,19 @@ import { type NodePossessionResponse, } from './node-possession-proof.js'; -const RECORD_SCHEMA = 'gh-aios.node-keystore-record/v1' as const; +export const NODE_KEYSTORE_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 { +export interface NodeKeyRecord { encryptedPrivateKeyPkcs8: string; nodeId: string; nodeKeyFingerprint: string; nodeType: 'local-terminal' | 'cloud-resident'; publicKeyPem: string; - schema: typeof RECORD_SCHEMA; + schema: typeof NODE_KEYSTORE_RECORD_SCHEMA; } export interface NodeKeyStoreAdapter { @@ -43,7 +43,7 @@ function hasExactKeys(record: Record, keys: string[]): boolean return actual.length === expected.length && actual.every((key, index) => key === expected[index]); } -function parseRecord(input: unknown): NodeKeyRecord | null { +export function parseNodeKeyRecord(input: unknown): NodeKeyRecord | null { if (!isRecord(input) || !hasExactKeys(input, [ 'encryptedPrivateKeyPkcs8', @@ -53,7 +53,7 @@ function parseRecord(input: unknown): NodeKeyRecord | null { 'publicKeyPem', 'schema', ]) - || input.schema !== RECORD_SCHEMA + || input.schema !== NODE_KEYSTORE_RECORD_SCHEMA || typeof input.nodeId !== 'string' || !IDENTIFIER_PATTERN.test(input.nodeId) || (input.nodeType !== 'local-terminal' && input.nodeType !== 'cloud-resident') @@ -69,7 +69,7 @@ function parseRecord(input: unknown): NodeKeyRecord | null { return input as unknown as NodeKeyRecord; } -function publicKeyFingerprint(publicKeyPem: string): string | null { +export function nodePublicKeyFingerprint(publicKeyPem: string): string | null { try { const publicKey = createPublicKey({ key: publicKeyPem, format: 'pem' }); if (publicKey.asymmetricKeyType !== 'ed25519') return null; @@ -92,12 +92,12 @@ export class EncryptedNodeKeyStoreProofSource implements NodePossessionProofSour try { assertActiveNodePossessionChallenge(challenge, this.clock()); if (!this.adapter.isAvailable()) return null; - const record = parseRecord(await this.adapter.readRecord()); + const record = parseNodeKeyRecord(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; + || nodePublicKeyFingerprint(record.publicKeyPem) !== record.nodeKeyFingerprint) return null; privateKeyPkcs8 = await this.adapter.decryptPrivateKeyPkcs8(record.encryptedPrivateKeyPkcs8); if (!privateKeyPkcs8 || privateKeyPkcs8.length === 0 || privateKeyPkcs8.length > 16_384) return null; diff --git a/product-source/guanghu-knowledge-base/server/node-keystore-enrollment.test.ts b/product-source/guanghu-knowledge-base/server/node-keystore-enrollment.test.ts new file mode 100644 index 0000000..7839ec5 --- /dev/null +++ b/product-source/guanghu-knowledge-base/server/node-keystore-enrollment.test.ts @@ -0,0 +1,79 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createPrivateKey, createPublicKey } from 'node:crypto'; +import { EncryptedLocalNodeEnrollmentStore, type NodeKeyEnrollmentAdapter } from './node-keystore-enrollment.js'; +import { nodePublicKeyFingerprint, parseNodeKeyRecord, type NodeKeyRecord } from './node-keystore-bridge.js'; + +function adapter(overrides: Partial = {}) { + let record: Readonly | null = null; + let exportedPrivateKey: Buffer | null = null; + const value: NodeKeyEnrollmentAdapter = { + isAvailable: () => true, + readRecord: async () => record, + async encryptPrivateKeyPkcs8(privateKeyPkcs8) { + exportedPrivateKey = Buffer.from(privateKeyPkcs8); + return Buffer.from('sealed-local-node-key').toString('base64'); + }, + async writeRecord(next) { record = next; }, + ...overrides, + }; + return { adapter: value, privateKey: () => exportedPrivateKey, record: () => record }; +} + +test('creates one encrypted local-terminal identity and returns only public enrollment material', async () => { + const harness = adapter(); + const store = new EncryptedLocalNodeEnrollmentStore(harness.adapter, () => 'HL-LOCAL-TEST-001'); + const enrollment = await store.ensureLocalTerminal(); + const record = parseNodeKeyRecord(harness.record()); + assert.ok(record); + assert.equal(enrollment.created, true); + assert.equal(enrollment.registrationState, 'LOCAL_KEY_READY'); + assert.equal(enrollment.nodeId, 'HL-LOCAL-TEST-001'); + assert.equal(enrollment.nodeKeyFingerprint, nodePublicKeyFingerprint(enrollment.publicKeyPem)); + assert.deepEqual(Object.keys(enrollment).sort(), [ + 'created', 'nodeId', 'nodeKeyFingerprint', 'nodeType', 'publicKeyPem', 'registrationState', + ]); + assert.equal('encryptedPrivateKeyPkcs8' in enrollment, false); + + const privateKey = createPrivateKey({ key: harness.privateKey()!, format: 'der', type: 'pkcs8' }); + const derivedPublic = createPublicKey(privateKey).export({ format: 'pem', type: 'spki' }).toString(); + assert.equal(derivedPublic, enrollment.publicKeyPem); +}); + +test('reuses a valid enrolled identity and never rotates it implicitly', async () => { + const harness = adapter(); + const firstStore = new EncryptedLocalNodeEnrollmentStore(harness.adapter, () => 'HL-LOCAL-TEST-001'); + const first = await firstStore.ensureLocalTerminal(); + const secondStore = new EncryptedLocalNodeEnrollmentStore(harness.adapter, () => 'HL-LOCAL-TEST-002'); + const second = await secondStore.ensureLocalTerminal(); + assert.equal(second.created, false); + assert.equal(second.nodeId, first.nodeId); + assert.equal(second.nodeKeyFingerprint, first.nodeKeyFingerprint); +}); + +test('fails closed when encryption is unavailable or an existing record is malformed', async () => { + const unavailable = adapter({ isAvailable: () => false }); + await assert.rejects( + new EncryptedLocalNodeEnrollmentStore(unavailable.adapter).ensureLocalTerminal(), + /node_keystore_encryption_unavailable/, + ); + + const malformed = adapter({ readRecord: async () => ({ schema: 'wrong' }) }); + await assert.rejects( + new EncryptedLocalNodeEnrollmentStore(malformed.adapter).ensureLocalTerminal(), + /node_keystore_record_invalid/, + ); +}); + +test('does not write a record when encryption output is invalid', async () => { + let writes = 0; + const harness = adapter({ + encryptPrivateKeyPkcs8: async () => '***', + writeRecord: async () => { writes += 1; }, + }); + await assert.rejects( + new EncryptedLocalNodeEnrollmentStore(harness.adapter).ensureLocalTerminal(), + /node_keystore_enrollment_failed/, + ); + assert.equal(writes, 0); +}); diff --git a/product-source/guanghu-knowledge-base/server/node-keystore-enrollment.ts b/product-source/guanghu-knowledge-base/server/node-keystore-enrollment.ts new file mode 100644 index 0000000..6511f8d --- /dev/null +++ b/product-source/guanghu-knowledge-base/server/node-keystore-enrollment.ts @@ -0,0 +1,86 @@ +import { generateKeyPairSync, randomUUID } from 'node:crypto'; +import { + NODE_KEYSTORE_RECORD_SCHEMA, + nodePublicKeyFingerprint, + parseNodeKeyRecord, + type NodeKeyRecord, +} from './node-keystore-bridge.js'; + +const BASE64_PATTERN = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/; + +export interface NodeKeyEnrollmentAdapter { + encryptPrivateKeyPkcs8(privateKeyPkcs8: Buffer): Promise; + isAvailable(): boolean; + readRecord(): Promise; + writeRecord(record: Readonly): Promise; +} + +export interface PublicNodeKeyEnrollment { + created: boolean; + nodeId: string; + nodeKeyFingerprint: string; + nodeType: 'local-terminal'; + publicKeyPem: string; + registrationState: 'LOCAL_KEY_READY'; +} + +function publicProjection(record: Readonly, created: boolean): Readonly { + if (record.nodeType !== 'local-terminal') throw new Error('node_keystore_wrong_node_type'); + return Object.freeze({ + created, + nodeId: record.nodeId, + nodeKeyFingerprint: record.nodeKeyFingerprint, + nodeType: record.nodeType, + publicKeyPem: record.publicKeyPem, + registrationState: 'LOCAL_KEY_READY', + }); +} + +export class EncryptedLocalNodeEnrollmentStore { + constructor( + private readonly adapter: NodeKeyEnrollmentAdapter, + private readonly nodeIdFactory: () => string = () => `HL-LOCAL-${randomUUID().toUpperCase()}`, + ) {} + + async status(): Promise | null> { + const raw = await this.adapter.readRecord(); + if (raw === null) return null; + const record = parseNodeKeyRecord(raw); + if (!record) throw new Error('node_keystore_record_invalid'); + return publicProjection(record, false); + } + + async ensureLocalTerminal(): Promise> { + if (!this.adapter.isAvailable()) throw new Error('node_keystore_encryption_unavailable'); + const existing = await this.status(); + if (existing) return existing; + + let privateKeyPkcs8: Buffer | null = null; + try { + const { privateKey, publicKey } = generateKeyPairSync('ed25519'); + privateKeyPkcs8 = privateKey.export({ format: 'der', type: 'pkcs8' }); + const publicKeyPem = publicKey.export({ format: 'pem', type: 'spki' }).toString(); + const nodeKeyFingerprint = nodePublicKeyFingerprint(publicKeyPem); + const encryptedPrivateKeyPkcs8 = await this.adapter.encryptPrivateKeyPkcs8(privateKeyPkcs8); + if (!nodeKeyFingerprint + || !encryptedPrivateKeyPkcs8 + || encryptedPrivateKeyPkcs8.length > 65_536 + || !BASE64_PATTERN.test(encryptedPrivateKeyPkcs8)) { + throw new Error('node_keystore_enrollment_failed'); + } + const record: Readonly = Object.freeze({ + encryptedPrivateKeyPkcs8, + nodeId: this.nodeIdFactory(), + nodeKeyFingerprint, + nodeType: 'local-terminal', + publicKeyPem, + schema: NODE_KEYSTORE_RECORD_SCHEMA, + }); + if (!parseNodeKeyRecord(record)) throw new Error('node_keystore_enrollment_failed'); + await this.adapter.writeRecord(record); + return publicProjection(record, true); + } finally { + privateKeyPkcs8?.fill(0); + } + } +} diff --git a/product-source/hololake-desktop/electron/main.ts b/product-source/hololake-desktop/electron/main.ts index 87cf8d2..53fc317 100644 --- a/product-source/hololake-desktop/electron/main.ts +++ b/product-source/hololake-desktop/electron/main.ts @@ -17,12 +17,21 @@ import { app, BrowserWindow, shell, dialog, ipcMain, safeStorage } from 'electro import path from 'path'; import { spawn, ChildProcess } from 'child_process'; import fs from 'fs'; +import { randomUUID } from 'node:crypto'; import { importKnowledgeFolder } from './folder-import.js'; import { DomainAccessOrchestrator } from '../../guanghu-knowledge-base/server/domain-access-orchestrator.js'; import { TrustedSignerSnapshotLoader } from '../../guanghu-knowledge-base/server/trusted-signer-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 { EncryptedNodeKeyStoreProofSource } from '../../guanghu-knowledge-base/server/node-keystore-bridge.js'; +import { + EncryptedNodeKeyStoreProofSource, + type NodeKeyRecord, + type NodeKeyStoreAdapter, +} from '../../guanghu-knowledge-base/server/node-keystore-bridge.js'; +import { + EncryptedLocalNodeEnrollmentStore, + type NodeKeyEnrollmentAdapter, +} from '../../guanghu-knowledge-base/server/node-keystore-enrollment.js'; import { DomainRuntimeHandoffSnapshotLoader } from '../../guanghu-knowledge-base/server/domain-runtime-handoff-snapshot.js'; import { AnchoredDomainRuntimeHandoffSource, @@ -368,17 +377,18 @@ const domainRuntimeHandoffs = new AnchoredDomainRuntimeHandoffSource( new HttpDomainRuntimeHandoffTransport(), ); -const nodeKeyStoreProofs = new EncryptedNodeKeyStoreProofSource({ +const nodeKeyStoreAdapter: NodeKeyStoreAdapter & NodeKeyEnrollmentAdapter = { isAvailable() { return safeStorage.isEncryptionAvailable(); }, async readRecord() { try { const stat = fs.statSync(NODE_KEYSTORE_PATH); - if (!stat.isFile() || (stat.mode & 0o077) !== 0) return null; + if (!stat.isFile() || (stat.mode & 0o077) !== 0) throw new Error('node_keystore_record_unreadable'); return JSON.parse(fs.readFileSync(NODE_KEYSTORE_PATH, 'utf8')) as unknown; - } catch { - return null; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; + throw new Error('node_keystore_record_unreadable'); } }, async decryptPrivateKeyPkcs8(encrypted) { @@ -397,7 +407,26 @@ const nodeKeyStoreProofs = new EncryptedNodeKeyStoreProofSource({ return null; } }, -}); + async encryptPrivateKeyPkcs8(privateKeyPkcs8: Buffer) { + if (!safeStorage.isEncryptionAvailable()) return null; + return safeStorage.encryptString(privateKeyPkcs8.toString('base64')).toString('base64'); + }, + async writeRecord(record: Readonly) { + fs.mkdirSync(path.dirname(NODE_KEYSTORE_PATH), { recursive: true }); + const temporaryPath = `${NODE_KEYSTORE_PATH}.${process.pid}.${randomUUID()}.tmp`; + try { + fs.writeFileSync(temporaryPath, JSON.stringify(record), { flag: 'wx', mode: 0o600 }); + fs.renameSync(temporaryPath, NODE_KEYSTORE_PATH); + fs.chmodSync(NODE_KEYSTORE_PATH, 0o600); + } catch (error) { + try { fs.unlinkSync(temporaryPath); } catch {} + throw error; + } + }, +}; + +const nodeKeyStoreProofs = new EncryptedNodeKeyStoreProofSource(nodeKeyStoreAdapter); +const localNodeEnrollments = new EncryptedLocalNodeEnrollmentStore(nodeKeyStoreAdapter); const domainAccessOrchestrator = new DomainAccessOrchestrator( trustedSignerSnapshots, @@ -685,6 +714,8 @@ ipcMain.handle('server:domain-access', async (_event, input: { domainId?: unknow if (!nodeType) throw new Error('domain_access_node_type_invalid'); return domainAccessOrchestrator.domainAccess(domainId, nodeType); }); +ipcMain.handle('server:local-node-status', async () => localNodeEnrollments.status()); +ipcMain.handle('server:ensure-local-node', async () => localNodeEnrollments.ensureLocalTerminal()); ipcMain.handle('server:login', async (_event, input: { nodeId: string; username: string; password: string }) => { const username = String(input.username || '').trim(); const password = String(input.password || ''); diff --git a/product-source/hololake-desktop/electron/preload.ts b/product-source/hololake-desktop/electron/preload.ts index 6e0eef7..6e3b024 100644 --- a/product-source/hololake-desktop/electron/preload.ts +++ b/product-source/hololake-desktop/electron/preload.ts @@ -33,6 +33,8 @@ contextBridge.exposeInMainWorld('hololake', { domainRegistry: () => ipcRenderer.invoke('server:domain-registry'), domainAccess: (domainId: string, nodeType: 'local-terminal' | 'cloud-resident') => ipcRenderer.invoke('server:domain-access', { domainId, nodeType }), + localNodeStatus: () => ipcRenderer.invoke('server:local-node-status'), + ensureLocalNode: () => ipcRenderer.invoke('server:ensure-local-node'), session: (nodeId?: string) => ipcRenderer.invoke('server:session', nodeId), login: (input: { nodeId: string; username: string; password: string }) => ipcRenderer.invoke('server:login', input), diff --git a/product-source/hololake-platform/architecture/HOLOLAKE-LOCAL-NODE-ENROLLMENT-CONTRACT-20260810.md b/product-source/hololake-platform/architecture/HOLOLAKE-LOCAL-NODE-ENROLLMENT-CONTRACT-20260810.md new file mode 100644 index 0000000..aff6ea7 --- /dev/null +++ b/product-source/hololake-platform/architecture/HOLOLAKE-LOCAL-NODE-ENROLLMENT-CONTRACT-20260810.md @@ -0,0 +1,21 @@ +# HoloLake 本机终端节点身份材料契约 · 2026-08-10 + +> 阶段:P2 最小阶段 · `LOCAL_IMPLEMENTED` 候选 + +## 验收目标 + +1. 仅在 Electron `safeStorage` 可用时生成 Ed25519 本机节点密钥; +2. 私钥只以 PKCS#8 字节进入加密适配器,持久化记录只保存加密密文; +3. 节点记录原子写入 `node-keystore.json`,文件权限固定为 `0600`; +4. 已有合法本机节点身份时返回同一公开身份,不自动轮换; +5. 已有记录损坏、节点类型不符、加密不可用或密文格式异常时失败关闭; +6. 渲染进程只能取得节点编号、节点类型、公开密钥、指纹和本地材料状态; +7. 生成本机身份材料不等于灯塔登记,不等于域授权,不等于会话能力,不等于远端在线。 + +## 状态语义 + +`LOCAL_KEY_READY` 只证明本机存在一个由 macOS 加密存储保护、可用于后续节点持有证明的 +密钥材料。只有灯塔返回当前有效的签名节点登记、节点完成新鲜挑战证明、目标域签发短期 +能力并返回连接回执后,才允许把节点标记为已登记或已接入。 + +本阶段不新增静态口令,不读取或显示私钥,不自动上传公开密钥,也不创建虚假登记记录。