From fc226b1f1893f443a5d6a9a9c608406fc921d3b8 Mon Sep 17 00:00:00 2001 From: nav Date: Wed, 8 Apr 2026 21:34:38 +0000 Subject: [PATCH] feat(client): add keypair generation --- plugin/core/runtime.ts | 14 +++-- plugin/core/state.ts | 26 +++++++++ plugin/crypto/keypair.ts | 119 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 155 insertions(+), 4 deletions(-) create mode 100644 plugin/crypto/keypair.ts diff --git a/plugin/core/runtime.ts b/plugin/core/runtime.ts index 9790091..0aa484c 100644 --- a/plugin/core/runtime.ts +++ b/plugin/core/runtime.ts @@ -9,11 +9,12 @@ import { } from "../../../Yonexus.Protocol/src/index.js"; import type { YonexusClientConfig } from "./config.js"; import { - createInitialClientState, + ensureClientKeyPair, hasClientKeyPair, hasClientSecret, - type YonexusClientState, - type YonexusClientStateStore + createInitialClientState, + YonexusClientState, + YonexusClientStateStore } from "./state.js"; import type { ClientConnectionState, ClientTransport } from "./transport.js"; @@ -66,7 +67,12 @@ export class YonexusClientRuntime { } this.phase = "starting"; - this.clientState = await this.options.stateStore.load(this.options.config.identifier); + + // Load existing state and ensure key pair exists + let state = await this.options.stateStore.load(this.options.config.identifier); + const keyResult = await ensureClientKeyPair(state, this.options.stateStore); + this.clientState = keyResult.state; + await this.options.transport.connect(); } diff --git a/plugin/core/state.ts b/plugin/core/state.ts index fa77aca..f6a9b08 100644 --- a/plugin/core/state.ts +++ b/plugin/core/state.ts @@ -1,5 +1,6 @@ import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; import { dirname } from "node:path"; +import { generateKeyPair, type KeyPair } from "../crypto/keypair.js"; export const CLIENT_STATE_VERSION = 1; @@ -132,6 +133,31 @@ export function hasClientKeyPair(state: YonexusClientState): boolean { ); } +/** + * Ensure the client state has a valid key pair. + * If no key pair exists, generates a new Ed25519 key pair. + * Returns the (possibly updated) state and whether a new key was generated. + */ +export async function ensureClientKeyPair( + state: YonexusClientState, + stateStore: YonexusClientStateStore +): Promise<{ state: YonexusClientState; generated: boolean }> { + if (hasClientKeyPair(state)) { + return { state, generated: false }; + } + + const keyPair = await generateKeyPair(); + const updatedState: YonexusClientState = { + ...state, + privateKey: keyPair.privateKey, + publicKey: keyPair.publicKey, + updatedAt: Math.floor(Date.now() / 1000) + }; + + await stateStore.save(updatedState); + return { state: updatedState, generated: true }; +} + function assertClientStateShape( value: unknown, filePath: string diff --git a/plugin/crypto/keypair.ts b/plugin/crypto/keypair.ts new file mode 100644 index 0000000..26bfca5 --- /dev/null +++ b/plugin/crypto/keypair.ts @@ -0,0 +1,119 @@ +import { randomBytes, sign as signMessageRaw, verify as verifySignatureRaw } from "node:crypto"; + +/** + * Key pair for Yonexus client authentication + * Uses Ed25519 for digital signatures + */ +export interface KeyPair { + /** Base64-encoded Ed25519 private key */ + readonly privateKey: string; + /** Base64-encoded Ed25519 public key */ + readonly publicKey: string; + /** Algorithm identifier for compatibility */ + readonly algorithm: "Ed25519"; +} + +/** + * Generate a new Ed25519 key pair for client authentication. + * + * In v1, we use Node.js crypto.generateKeyPairSync for Ed25519. + * The keys are encoded as base64 for JSON serialization. + */ +export async function generateKeyPair(): Promise { + const { generateKeyPair } = await import("node:crypto"); + + const { publicKey, privateKey } = await new Promise<{ + publicKey: string; + privateKey: string; + }>((resolve, reject) => { + generateKeyPair( + "ed25519", + { + publicKeyEncoding: { type: "spki", format: "pem" }, + privateKeyEncoding: { type: "pkcs8", format: "pem" } + }, + (err, pubKey, privKey) => { + if (err) { + reject(err); + return; + } + resolve({ publicKey: pubKey, privateKey: privKey }); + } + ); + }); + + return { + privateKey, + publicKey, + algorithm: "Ed25519" + }; +} + +/** + * Sign a message using the client's private key. + * + * @param privateKeyPem - PEM-encoded private key + * @param message - Message to sign (Buffer or string) + * @returns Base64-encoded signature + */ +export async function signMessage( + privateKeyPem: string, + message: Buffer | string +): Promise { + const signature = signMessageRaw(null, typeof message === "string" ? Buffer.from(message) : message, privateKeyPem); + return signature.toString("base64"); +} + +/** + * Verify a signature using the client's public key. + * + * @param publicKeyPem - PEM-encoded public key + * @param message - Original message (Buffer or string) + * @param signature - Base64-encoded signature + * @returns Whether the signature is valid + */ +export async function verifySignature( + publicKeyPem: string, + message: Buffer | string, + signature: string +): Promise { + try { + const sigBuffer = Buffer.from(signature, "base64"); + return verifySignatureRaw(null, typeof message === "string" ? Buffer.from(message) : message, publicKeyPem, sigBuffer); + } catch { + return false; + } +} + +/** + * Generate a cryptographically secure random pairing code. + * Format: XXXX-XXXX-XXXX (12 alphanumeric characters in groups of 4) + */ +export function generatePairingCode(): string { + const bytes = randomBytes(8); + const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // Excludes confusing chars (0, O, 1, I) + + let code = ""; + for (let i = 0; i < 12; i++) { + code += chars[bytes[i % bytes.length] % chars.length]; + } + + // Format as XXXX-XXXX-XXXX + return `${code.slice(0, 4)}-${code.slice(4, 8)}-${code.slice(8, 12)}`; +} + +/** + * Generate a shared secret for client authentication. + * This is issued by the server after successful pairing. + */ +export function generateSecret(): string { + return randomBytes(32).toString("base64url"); +} + +/** + * Generate a 24-character nonce for authentication. + */ +export function generateNonce(): string { + const bytes = randomBytes(18); + return bytes.toString("base64url").slice(0, 24); +}