dev/2026-04-08 #1
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
119
plugin/crypto/keypair.ts
Normal file
119
plugin/crypto/keypair.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user