86 lines
3.2 KiB
TypeScript
86 lines
3.2 KiB
TypeScript
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);
|
|
}
|
|
}
|
|
}
|