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