import { ethers, Wallet } from 'ethers'; import { Config } from './Config'; import { DomainConfig, ProviderConfig } from './types'; import { defaultChain, providerConfigFor } from '../chains'; import fs from 'fs'; import { join } from 'path'; import { createHash, randomBytes } from 'crypto'; /** * The request a bot signature authorises. Either `url`, or both `uri` and * `aud`, must be supplied — a bot signature that names no request is a bearer * token, which is the thing this shape exists to prevent. */ export interface BotAuthRequest { method?: string; uri?: string; aud?: string; url?: string; body?: string | Buffer | null; } /** * CliWallet - Manages wallet operations for CLI/bot contexts * * Uses Epistery's domain configuration system: * - Domain configs stored in ~/.epistery/{domain}/config.ini * - Each domain has its own wallet (like server-side) * - Default domain configurable in ~/.epistery/config.ini [cli] section * - Automatic wallet creation on initialize * * This matches the server-side model where each domain has a wallet, * making CLI usage consistent with server architecture. */ // Duplicates types.ts::KeyExchangeRequest — kept here so the CLI module // doesn't have to reach across the package. Must stay in lockstep. export interface KeyExchangeRequest { signerAddress: string; signerPublicKey: string; contractAddress?: string | null; challenge: string; message: string; signature: string; walletSource: string; } export interface KeyExchangeResponse { serverAddress: string; serverPublicKey: string; services: string[]; challenge: string; signature: string; identified: boolean; authenticated?: boolean; profile?: any; } export interface SessionInfo { domain: string; cookie: string; authenticated: boolean; timestamp: string; } export class CliWallet { private config: Config; private domainName: string; private domainConfig: DomainConfig; private wallet: Wallet; public address: string; public publicKey: string; private constructor(config: Config, domainName: string, domainConfig: DomainConfig, wallet: Wallet) { this.config = config; this.domainName = domainName.toLowerCase(); this.domainConfig = domainConfig; this.wallet = wallet; this.address = wallet.address; this.publicKey = wallet.publicKey; } /** * Get the default domain from config.ini [cli] section */ static async getDefaultDomain(): Promise { const config = new Config(); await config.setPath('/'); return (config.data as any).cli?.default_domain || 'localhost'; } /** * Set the default domain in config.ini [cli] section. * * Loads the root config first so the rest of it ([profile], [default.provider], * …) is preserved on save. (Before the async migration, Config did not * auto-load on construction, so this wrote {cli:…} over the whole root file.) */ static async setDefaultDomain(domain: string): Promise { const config = new Config(); await config.setPath('/'); if (!(config.data as any).cli) { (config.data as any).cli = {}; } (config.data as any).cli.default_domain = domain; await config.save(); } /** * Initialize a new domain with wallet * Creates ~/.epistery/{domain}/config.ini with new wallet */ static async initialize(domain: string, provider?: ProviderConfig): Promise { const config = new Config(); // Check if domain already exists await config.setPath(domain); if (config.data.wallet) { throw new Error(`Domain '${domain}' already initialized. Use load() to access it.`); } // Create new wallet const ethersWallet = ethers.Wallet.createRandom(); // Chain comes from the caller (epistery initialize --chain) or, failing // that, from the configured default chain (registry: root [default] // defaultChainId / [default.provider], else Polygon mainnet). const providerConfig = provider ? providerConfigFor(provider) : providerConfigFor(await defaultChain()); // Create domain config await config.setPath(`/${domain}`); config.data = { domain: domain, wallet: { address: ethersWallet.address, mnemonic: ethersWallet.mnemonic?.phrase || '', publicKey: ethersWallet.publicKey, privateKey: ethersWallet.privateKey }, provider: providerConfig }; await config.save(); console.log(`Initialized domain: ${domain}`); console.log(`Address: ${ethersWallet.address}`); console.log(`Provider: ${providerConfig.name}`); return new CliWallet(config, domain, config.data, ethersWallet); } /** * Point an already-initialized domain at a different chain. * * Only the domain's [provider] block changes — the wallet (and therefore the * address) is chain-agnostic and is left exactly as it is. Returns the * previous provider config so callers can report the change. */ static async setChain(domain: string, provider: ProviderConfig): Promise { const config = new Config(); await config.setPath(`/${domain}`); if (!config.data.wallet) { throw new Error( `Domain '${domain}' not found or has no wallet. ` + `Initialize with: epistery initialize ${domain}` ); } const previous: ProviderConfig | undefined = config.data.provider; config.data.provider = providerConfigFor(provider); await config.save(); return previous; } /** * Load domain wallet from config * Throws if domain doesn't exist - use initialize() first */ static async load(domain?: string): Promise { const config = new Config(); const domainName = domain || await CliWallet.getDefaultDomain(); await config.setPath(`/${domainName}`); if (!config.data.wallet) { throw new Error( `Domain '${domainName}' not found or has no wallet. ` + `Initialize with: epistery initialize ${domainName}` ); } // Reconstruct wallet from config let ethersWallet: Wallet; if (config.data.wallet.mnemonic) { ethersWallet = ethers.Wallet.fromMnemonic(config.data.wallet.mnemonic); } else if (config.data.wallet.privateKey) { ethersWallet = new ethers.Wallet(config.data.wallet.privateKey); } else { throw new Error(`Domain '${domainName}' wallet has no mnemonic or privateKey`); } return new CliWallet(config, domainName, config.data, ethersWallet); } /** * Get domain name */ getDomain(): string { return this.domainName; } /** * Get provider info */ getProvider() { return this.domainConfig.provider; } /** * Sign a message */ async sign(message: string): Promise { return await this.wallet.signMessage(message); } /** * Perform key exchange with an Epistery server * Automatically saves session cookie to domain config */ async performKeyExchange(serverUrl: string): Promise { // Ensure server URL is properly formatted const baseUrl = serverUrl.replace(/\/$/, ''); const connectUrl = `${baseUrl}/.well-known/epistery/connect`; // Generate challenge for key exchange const challenge = ethers.utils.hexlify(ethers.utils.randomBytes(32)); const message = `Epistery Key Exchange - ${this.address} - ${challenge}`; // Sign the message const signature = await this.sign(message); // Prepare key exchange request. The CLI never claims a contract via // session cookie today (CLI is bot-auth-first), so contractAddress is // null; the server treats it as a signer-only session. const requestData: KeyExchangeRequest = { signerAddress: this.address, signerPublicKey: this.publicKey, contractAddress: null, challenge: challenge, message: message, signature: signature, walletSource: 'server' }; // Perform key exchange const response = await fetch(connectUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(requestData) }); if (!response.ok) { const errorText = await response.text(); throw new Error(`Key exchange failed: ${response.status} - ${errorText}`); } const serverResponse = await response.json() as KeyExchangeResponse; // Verify server's identity const expectedMessage = `Epistery Server Response - ${serverResponse.serverAddress} - ${serverResponse.challenge}`; const recoveredAddress = ethers.utils.verifyMessage(expectedMessage, serverResponse.signature); if (recoveredAddress.toLowerCase() !== serverResponse.serverAddress.toLowerCase()) { throw new Error('Server identity verification failed'); } // Extract and save session cookie if present const cookies = response.headers.get('set-cookie'); if (cookies) { const sessionMatch = cookies.match(/_epistery=([^;]+)/); if (sessionMatch) { const sessionToken = sessionMatch[1]; // Save session to domain config this.saveSession({ domain: serverUrl, cookie: sessionToken, authenticated: serverResponse.authenticated || false, timestamp: new Date().toISOString() }); } } return serverResponse; } /** * Get saved session for a specific server URL */ getSession(serverUrl: string): SessionInfo | null { const sessionFile = this.getSessionFilePath(serverUrl); if (!fs.existsSync(sessionFile)) { return null; } try { const data = fs.readFileSync(sessionFile, 'utf8'); return JSON.parse(data) as SessionInfo; } catch (error) { return null; } } /** * Save session info to domain directory, keyed by server URL */ private saveSession(session: SessionInfo): void { const sessionFile = this.getSessionFilePath(session.domain); fs.writeFileSync(sessionFile, JSON.stringify(session, null, 2), { mode: 0o600 }); } /** * Clear saved session for a server URL */ clearSession(serverUrl: string): void { const sessionFile = this.getSessionFilePath(serverUrl); if (fs.existsSync(sessionFile)) { fs.unlinkSync(sessionFile); } } /** * Get session file path for a server URL * Hashes the server URL to create a safe filename */ private getSessionFilePath(serverUrl: string): string { // Create a safe filename from the server URL const crypto = require('crypto'); const hash = crypto.createHash('md5').update(serverUrl).digest('hex'); const sessionsDir = join(this.config.configDir, this.domainName, 'sessions'); // Ensure sessions directory exists if (!fs.existsSync(sessionsDir)) { fs.mkdirSync(sessionsDir, { mode: 0o700, recursive: true }); } return join(sessionsDir, `${hash}.json`); } /** * Create bot authentication header for a specific request. * * The signature covers the request: method, URI, audience host and a digest * of the body, plus a timestamp and a single-use nonce. A header minted for * one call cannot be replayed against a different endpoint, a different host, * or the same endpoint with a different body. * * The signed bytes are built by `client/bot-auth-message.mjs`, which is also * what the server verifier uses. Never inline the message here. * * Format: Authorization: Bot * * @param req.method HTTP method * @param req.uri path + query exactly as it will be sent * @param req.url alternative to uri: a full URL, from which the uri and * audience host are derived * @param req.aud audience host; defaults to the host in `req.url` * @param req.body request body as sent — string, Buffer or undefined */ async createBotAuthHeader(req: BotAuthRequest = {}): Promise { const { botAuthMessage, audienceFor, EMPTY_BODY_SHA256 } = await import( '../../client/bot-auth-message.mjs' as string ); let uri = req.uri; let aud = req.aud; if (req.url) { const u = new URL(req.url); if (uri === undefined) uri = u.pathname + u.search; if (aud === undefined) aud = u.host; } if (uri === undefined || aud === undefined) { throw new Error( 'createBotAuthHeader: pass { url } or both { uri, aud } — a bot signature must name the request it authorises' ); } const body = req.body; const bodyHash = body === undefined || body === null || body.length === 0 ? EMPTY_BODY_SHA256 : createHash('sha256') .update(Buffer.isBuffer(body) ? body : Buffer.from(body, 'utf8')) .digest('hex'); const ts = Date.now(); const nonce = randomBytes(16).toString('hex'); const method = (req.method || 'POST').toUpperCase(); const audience = audienceFor(aud); const message = botAuthMessage({ method, uri, aud: audience, bodyHashHex: bodyHash, ts, nonce }); const signature = await this.sign(message); const payload = { v: '1', address: this.address, signature, method, uri, aud: audience, bodyHash, ts, nonce }; return `Bot ${Buffer.from(JSON.stringify(payload)).toString('base64')}`; } /** * Export wallet data (for migration or backup) */ toJSON() { return { domain: this.domainName, address: this.wallet.address, publicKey: this.wallet.publicKey, provider: this.domainConfig.provider }; } }