feat: add encrypted local node enrollment
This commit is contained in:
parent
683e135f5e
commit
d2ec5c6c87
6 changed files with 233 additions and 14 deletions
|
|
@ -12,19 +12,19 @@ import {
|
||||||
type NodePossessionResponse,
|
type NodePossessionResponse,
|
||||||
} from './node-possession-proof.js';
|
} 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 RESPONSE_SCHEMA = 'gh-aios.node-possession-response/v1' as const;
|
||||||
const IDENTIFIER_PATTERN = /^[A-Z0-9][A-Z0-9._:-]{1,159}$/;
|
const IDENTIFIER_PATTERN = /^[A-Z0-9][A-Z0-9._:-]{1,159}$/;
|
||||||
const FINGERPRINT_PATTERN = /^[a-f0-9]{64}$/;
|
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}=)?$/;
|
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;
|
encryptedPrivateKeyPkcs8: string;
|
||||||
nodeId: string;
|
nodeId: string;
|
||||||
nodeKeyFingerprint: string;
|
nodeKeyFingerprint: string;
|
||||||
nodeType: 'local-terminal' | 'cloud-resident';
|
nodeType: 'local-terminal' | 'cloud-resident';
|
||||||
publicKeyPem: string;
|
publicKeyPem: string;
|
||||||
schema: typeof RECORD_SCHEMA;
|
schema: typeof NODE_KEYSTORE_RECORD_SCHEMA;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NodeKeyStoreAdapter {
|
export interface NodeKeyStoreAdapter {
|
||||||
|
|
@ -43,7 +43,7 @@ function hasExactKeys(record: Record<string, unknown>, keys: string[]): boolean
|
||||||
return actual.length === expected.length && actual.every((key, index) => key === expected[index]);
|
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)
|
if (!isRecord(input)
|
||||||
|| !hasExactKeys(input, [
|
|| !hasExactKeys(input, [
|
||||||
'encryptedPrivateKeyPkcs8',
|
'encryptedPrivateKeyPkcs8',
|
||||||
|
|
@ -53,7 +53,7 @@ function parseRecord(input: unknown): NodeKeyRecord | null {
|
||||||
'publicKeyPem',
|
'publicKeyPem',
|
||||||
'schema',
|
'schema',
|
||||||
])
|
])
|
||||||
|| input.schema !== RECORD_SCHEMA
|
|| input.schema !== NODE_KEYSTORE_RECORD_SCHEMA
|
||||||
|| typeof input.nodeId !== 'string'
|
|| typeof input.nodeId !== 'string'
|
||||||
|| !IDENTIFIER_PATTERN.test(input.nodeId)
|
|| !IDENTIFIER_PATTERN.test(input.nodeId)
|
||||||
|| (input.nodeType !== 'local-terminal' && input.nodeType !== 'cloud-resident')
|
|| (input.nodeType !== 'local-terminal' && input.nodeType !== 'cloud-resident')
|
||||||
|
|
@ -69,7 +69,7 @@ function parseRecord(input: unknown): NodeKeyRecord | null {
|
||||||
return input as unknown as NodeKeyRecord;
|
return input as unknown as NodeKeyRecord;
|
||||||
}
|
}
|
||||||
|
|
||||||
function publicKeyFingerprint(publicKeyPem: string): string | null {
|
export function nodePublicKeyFingerprint(publicKeyPem: string): string | null {
|
||||||
try {
|
try {
|
||||||
const publicKey = createPublicKey({ key: publicKeyPem, format: 'pem' });
|
const publicKey = createPublicKey({ key: publicKeyPem, format: 'pem' });
|
||||||
if (publicKey.asymmetricKeyType !== 'ed25519') return null;
|
if (publicKey.asymmetricKeyType !== 'ed25519') return null;
|
||||||
|
|
@ -92,12 +92,12 @@ export class EncryptedNodeKeyStoreProofSource implements NodePossessionProofSour
|
||||||
try {
|
try {
|
||||||
assertActiveNodePossessionChallenge(challenge, this.clock());
|
assertActiveNodePossessionChallenge(challenge, this.clock());
|
||||||
if (!this.adapter.isAvailable()) return null;
|
if (!this.adapter.isAvailable()) return null;
|
||||||
const record = parseRecord(await this.adapter.readRecord());
|
const record = parseNodeKeyRecord(await this.adapter.readRecord());
|
||||||
if (!record
|
if (!record
|
||||||
|| record.nodeId !== challenge.nodeId
|
|| record.nodeId !== challenge.nodeId
|
||||||
|| record.nodeType !== challenge.nodeType
|
|| record.nodeType !== challenge.nodeType
|
||||||
|| record.nodeKeyFingerprint !== challenge.nodeKeyFingerprint
|
|| 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);
|
privateKeyPkcs8 = await this.adapter.decryptPrivateKeyPkcs8(record.encryptedPrivateKeyPkcs8);
|
||||||
if (!privateKeyPkcs8 || privateKeyPkcs8.length === 0 || privateKeyPkcs8.length > 16_384) return null;
|
if (!privateKeyPkcs8 || privateKeyPkcs8.length === 0 || privateKeyPkcs8.length > 16_384) return null;
|
||||||
|
|
|
||||||
|
|
@ -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<NodeKeyEnrollmentAdapter> = {}) {
|
||||||
|
let record: Readonly<NodeKeyRecord> | 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);
|
||||||
|
});
|
||||||
|
|
@ -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<string | null>;
|
||||||
|
isAvailable(): boolean;
|
||||||
|
readRecord(): Promise<unknown | null>;
|
||||||
|
writeRecord(record: Readonly<NodeKeyRecord>): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PublicNodeKeyEnrollment {
|
||||||
|
created: boolean;
|
||||||
|
nodeId: string;
|
||||||
|
nodeKeyFingerprint: string;
|
||||||
|
nodeType: 'local-terminal';
|
||||||
|
publicKeyPem: string;
|
||||||
|
registrationState: 'LOCAL_KEY_READY';
|
||||||
|
}
|
||||||
|
|
||||||
|
function publicProjection(record: Readonly<NodeKeyRecord>, created: boolean): Readonly<PublicNodeKeyEnrollment> {
|
||||||
|
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<Readonly<PublicNodeKeyEnrollment> | 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<Readonly<PublicNodeKeyEnrollment>> {
|
||||||
|
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<NodeKeyRecord> = 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -17,12 +17,21 @@ import { app, BrowserWindow, shell, dialog, ipcMain, safeStorage } from 'electro
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { spawn, ChildProcess } from 'child_process';
|
import { spawn, ChildProcess } from 'child_process';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
import { importKnowledgeFolder } from './folder-import.js';
|
import { importKnowledgeFolder } from './folder-import.js';
|
||||||
import { DomainAccessOrchestrator } from '../../guanghu-knowledge-base/server/domain-access-orchestrator.js';
|
import { DomainAccessOrchestrator } from '../../guanghu-knowledge-base/server/domain-access-orchestrator.js';
|
||||||
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 {
|
||||||
|
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 { DomainRuntimeHandoffSnapshotLoader } from '../../guanghu-knowledge-base/server/domain-runtime-handoff-snapshot.js';
|
||||||
import {
|
import {
|
||||||
AnchoredDomainRuntimeHandoffSource,
|
AnchoredDomainRuntimeHandoffSource,
|
||||||
|
|
@ -368,17 +377,18 @@ const domainRuntimeHandoffs = new AnchoredDomainRuntimeHandoffSource(
|
||||||
new HttpDomainRuntimeHandoffTransport(),
|
new HttpDomainRuntimeHandoffTransport(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const nodeKeyStoreProofs = new EncryptedNodeKeyStoreProofSource({
|
const nodeKeyStoreAdapter: NodeKeyStoreAdapter & NodeKeyEnrollmentAdapter = {
|
||||||
isAvailable() {
|
isAvailable() {
|
||||||
return safeStorage.isEncryptionAvailable();
|
return safeStorage.isEncryptionAvailable();
|
||||||
},
|
},
|
||||||
async readRecord() {
|
async readRecord() {
|
||||||
try {
|
try {
|
||||||
const stat = fs.statSync(NODE_KEYSTORE_PATH);
|
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;
|
return JSON.parse(fs.readFileSync(NODE_KEYSTORE_PATH, 'utf8')) as unknown;
|
||||||
} catch {
|
} catch (error) {
|
||||||
return null;
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
|
||||||
|
throw new Error('node_keystore_record_unreadable');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async decryptPrivateKeyPkcs8(encrypted) {
|
async decryptPrivateKeyPkcs8(encrypted) {
|
||||||
|
|
@ -397,7 +407,26 @@ const nodeKeyStoreProofs = new EncryptedNodeKeyStoreProofSource({
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
async encryptPrivateKeyPkcs8(privateKeyPkcs8: Buffer) {
|
||||||
|
if (!safeStorage.isEncryptionAvailable()) return null;
|
||||||
|
return safeStorage.encryptString(privateKeyPkcs8.toString('base64')).toString('base64');
|
||||||
|
},
|
||||||
|
async writeRecord(record: Readonly<NodeKeyRecord>) {
|
||||||
|
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(
|
const domainAccessOrchestrator = new DomainAccessOrchestrator(
|
||||||
trustedSignerSnapshots,
|
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');
|
if (!nodeType) throw new Error('domain_access_node_type_invalid');
|
||||||
return domainAccessOrchestrator.domainAccess(domainId, nodeType);
|
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 }) => {
|
ipcMain.handle('server:login', async (_event, input: { nodeId: string; username: string; password: string }) => {
|
||||||
const username = String(input.username || '').trim();
|
const username = String(input.username || '').trim();
|
||||||
const password = String(input.password || '');
|
const password = String(input.password || '');
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,8 @@ contextBridge.exposeInMainWorld('hololake', {
|
||||||
domainRegistry: () => ipcRenderer.invoke('server:domain-registry'),
|
domainRegistry: () => ipcRenderer.invoke('server:domain-registry'),
|
||||||
domainAccess: (domainId: string, nodeType: 'local-terminal' | 'cloud-resident') =>
|
domainAccess: (domainId: string, nodeType: 'local-terminal' | 'cloud-resident') =>
|
||||||
ipcRenderer.invoke('server:domain-access', { domainId, nodeType }),
|
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),
|
session: (nodeId?: string) => ipcRenderer.invoke('server:session', nodeId),
|
||||||
login: (input: { nodeId: string; username: string; password: string }) =>
|
login: (input: { nodeId: string; username: string; password: string }) =>
|
||||||
ipcRenderer.invoke('server:login', input),
|
ipcRenderer.invoke('server:login', input),
|
||||||
|
|
|
||||||
|
|
@ -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 加密存储保护、可用于后续节点持有证明的
|
||||||
|
密钥材料。只有灯塔返回当前有效的签名节点登记、节点完成新鲜挑战证明、目标域签发短期
|
||||||
|
能力并返回连接回执后,才允许把节点标记为已登记或已接入。
|
||||||
|
|
||||||
|
本阶段不新增静态口令,不读取或显示私钥,不自动上传公开密钥,也不创建虚假登记记录。
|
||||||
Loading…
Reference in a new issue