import { createHash, createPrivateKey, createPublicKey, sign, } from 'node:crypto'; import { assertActiveNodePossessionChallenge, nodePossessionChallengeSigningBytes, type NodePossessionChallenge, type NodePossessionProofSource, type NodePossessionResponse, } from './node-possession-proof.js'; 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}=)?$/; export interface NodeKeyRecord { encryptedPrivateKeyPkcs8: string; nodeId: string; nodeKeyFingerprint: string; nodeType: 'local-terminal' | 'cloud-resident'; publicKeyPem: string; schema: typeof NODE_KEYSTORE_RECORD_SCHEMA; } export interface NodeKeyStoreAdapter { decryptPrivateKeyPkcs8(encrypted: string): Promise; isAvailable(): boolean; readRecord(): Promise; } function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } function hasExactKeys(record: Record, 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]); } export function parseNodeKeyRecord(input: unknown): NodeKeyRecord | null { if (!isRecord(input) || !hasExactKeys(input, [ 'encryptedPrivateKeyPkcs8', 'nodeId', 'nodeKeyFingerprint', 'nodeType', 'publicKeyPem', 'schema', ]) || input.schema !== NODE_KEYSTORE_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; } export function nodePublicKeyFingerprint(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): Promise | null> { let privateKeyPkcs8: Buffer | null = null; try { assertActiveNodePossessionChallenge(challenge, this.clock()); if (!this.adapter.isAvailable()) return null; const record = parseNodeKeyRecord(await this.adapter.readRecord()); if (!record || record.nodeId !== challenge.nodeId || record.nodeType !== challenge.nodeType || record.nodeKeyFingerprint !== challenge.nodeKeyFingerprint || 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; 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); } } }