dev/2026-04-08 #1
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
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