Compare commits

...

1 Commits

Author SHA1 Message Date
nav
fc226b1f18 feat(client): add keypair generation 2026-04-08 21:34:38 +00:00
3 changed files with 155 additions and 4 deletions

View File

@@ -9,11 +9,12 @@ import {
} from "../../../Yonexus.Protocol/src/index.js"; } from "../../../Yonexus.Protocol/src/index.js";
import type { YonexusClientConfig } from "./config.js"; import type { YonexusClientConfig } from "./config.js";
import { import {
createInitialClientState, ensureClientKeyPair,
hasClientKeyPair, hasClientKeyPair,
hasClientSecret, hasClientSecret,
type YonexusClientState, createInitialClientState,
type YonexusClientStateStore YonexusClientState,
YonexusClientStateStore
} from "./state.js"; } from "./state.js";
import type { ClientConnectionState, ClientTransport } from "./transport.js"; import type { ClientConnectionState, ClientTransport } from "./transport.js";
@@ -66,7 +67,12 @@ export class YonexusClientRuntime {
} }
this.phase = "starting"; 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(); await this.options.transport.connect();
} }

View File

@@ -1,5 +1,6 @@
import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import { dirname } from "node:path"; import { dirname } from "node:path";
import { generateKeyPair, type KeyPair } from "../crypto/keypair.js";
export const CLIENT_STATE_VERSION = 1; 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( function assertClientStateShape(
value: unknown, value: unknown,
filePath: string filePath: string

119
plugin/crypto/keypair.ts Normal file
View File

@@ -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<KeyPair> {
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<string> {
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<boolean> {
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);
}