feat: add encrypted local node enrollment

This commit is contained in:
冰朔 2026-08-10 11:09:12 +08:00
commit d2ec5c6c87
6 changed files with 233 additions and 14 deletions

View file

@ -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<string, unknown>, 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;

View file

@ -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);
});

View file

@ -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);
}
}
}