import crypto from 'crypto'; import { access, chmod, constants, mkdir, readFile, writeFile } from 'fs/promises'; import path from 'path'; export interface DeviceIdentity { deviceId: string; publicKeyPem: string; privateKeyPem: string; } export interface DeviceAuthPayloadParams { deviceId: string; clientId: string; clientMode: string; role: string; scopes: string[]; signedAtMs: number; token?: string | null; nonce?: string | null; version?: 'v1' | 'v2'; } const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex'); function base64UrlEncode(buffer: Buffer): string { return buffer .toString('base64') .replaceAll('+', '-') .replaceAll('/', '_') .replace(/=+$/g, ''); } function derivePublicKeyRaw(publicKeyPem: string): Buffer { const spki = crypto.createPublicKey(publicKeyPem).export({ type: 'spki', format: 'der', }) as Buffer; if ( spki.length === ED25519_SPKI_PREFIX.length + 32 && spki.subarray(0, ED25519_SPKI_PREFIX.length).equals(ED25519_SPKI_PREFIX) ) { return spki.subarray(ED25519_SPKI_PREFIX.length); } return spki; } function fingerprintPublicKey(publicKeyPem: string): string { const raw = derivePublicKeyRaw(publicKeyPem); return crypto.createHash('sha256').update(raw).digest('hex'); } async function fileExists(filePath: string): Promise { try { await access(filePath, constants.F_OK); return true; } catch { return false; } } async function generateIdentity(): Promise { const { publicKey, privateKey } = await new Promise( (resolve, reject) => { crypto.generateKeyPair('ed25519', (error, nextPublicKey, nextPrivateKey) => { if (error) { reject(error); return; } resolve({ publicKey: nextPublicKey, privateKey: nextPrivateKey, }); }); }, ); return { deviceId: fingerprintPublicKey(publicKey.export({ type: 'spki', format: 'pem' }).toString()), publicKeyPem: publicKey.export({ type: 'spki', format: 'pem' }).toString(), privateKeyPem: privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(), }; } export async function loadOrCreateDeviceIdentity(filePath: string): Promise { try { if (await fileExists(filePath)) { const raw = await readFile(filePath, 'utf-8'); const parsed = JSON.parse(raw); if ( parsed?.version === 1 && typeof parsed.deviceId === 'string' && typeof parsed.publicKeyPem === 'string' && typeof parsed.privateKeyPem === 'string' ) { const derivedId = fingerprintPublicKey(parsed.publicKeyPem); if (derivedId && derivedId !== parsed.deviceId) { const updated = { ...parsed, deviceId: derivedId }; await writeFile(filePath, `${JSON.stringify(updated, null, 2)}\n`, { mode: 0o600 }); return { deviceId: derivedId, publicKeyPem: parsed.publicKeyPem, privateKeyPem: parsed.privateKeyPem, }; } return { deviceId: parsed.deviceId, publicKeyPem: parsed.publicKeyPem, privateKeyPem: parsed.privateKeyPem, }; } } } catch { // Fall through and create a fresh identity. } const identity = await generateIdentity(); const dir = path.dirname(filePath); if (!(await fileExists(dir))) { await mkdir(dir, { recursive: true }); } await writeFile(filePath, `${JSON.stringify({ version: 1, ...identity, createdAtMs: Date.now(), }, null, 2)}\n`, { mode: 0o600 }); try { await chmod(filePath, 0o600); } catch { // Ignore chmod failures on unsupported filesystems. } return identity; } export function signDevicePayload(privateKeyPem: string, payload: string): string { const key = crypto.createPrivateKey(privateKeyPem); return base64UrlEncode(crypto.sign(null, Buffer.from(payload, 'utf-8'), key)); } export function publicKeyRawBase64UrlFromPem(publicKeyPem: string): string { return base64UrlEncode(derivePublicKeyRaw(publicKeyPem)); } export function buildDeviceAuthPayload(params: DeviceAuthPayloadParams): string { const version = params.version ?? (params.nonce ? 'v2' : 'v1'); const scopes = params.scopes.join(','); const token = params.token ?? ''; const payload = [ version, params.deviceId, params.clientId, params.clientMode, params.role, scopes, String(params.signedAtMs), token, ]; if (version === 'v2') { payload.push(params.nonce ?? ''); } return payload.join('|'); }