/** * Eth proof-owner key resolution — the raw secp256k1 private key passed to * attestor-core when the agent creates a local claim. It's a plain 0x-hex * Ethereum private key (no PEM), resolved from, in order: * * 1. `process.env.RECLAIM_PRIVATE_KEY` — a 0x-hex 32-byte scalar. * 2. A `.env` file in the project root — its `RECLAIM_PRIVATE_KEY=0x…` line. * 3. A key file holding the raw 0x-hex scalar, at `RECLAIM_PRIVATE_KEY_FILE` * (absolute or project-relative). * 4. Absent — the caller offers to generate one or asks the dev for a key. * * Generated/imported keys are written to the project `.env` (mode 0600, and the * file is added to `.gitignore`). NO `~/.reclaim` caching. This is a LOCAL * signing key only — unrelated to org auth (the org secret) or encryption * (the eth decrypt key). */ import { deriveFromPrivateKey } from '@reclaimprotocol/client' import assert from 'node:assert' import { existsSync, readFileSync, writeFileSync } from 'node:fs' import { isAbsolute, join } from 'node:path' import { privateKey, privateKeyFile } from '../../../consts.ts' import { findProjectRoot } from '../../../paths.ts' const ENV_VAR = 'RECLAIM_PRIVATE_KEY' /** The dotenv file the agent reads from / writes the key to. */ export const ENV_FILE = '.env' /** Where the resolved eth key came from. */ export type EthKeySource = 'env' | 'dotenv' | 'file' export type ResolvedEthKey = { /** Raw 0x-hex 32-byte scalar. */ privateKey: string /** Lowercase eth address derived from the scalar. */ address: string source: EthKeySource /** Absolute path the key was read from (for `dotenv` / `file` sources). */ path?: string } /** Validate + normalize a raw Ethereum private key to lowercase 0x-hex. */ export function normalizeEthHex(value: string): string { const v = value.trim().replace(/^['"]|['"]$/g, '') assert( /^(0x)?[0-9a-fA-F]{64}$/.test(v), new Error( 'not a valid Ethereum private key (expected a 32-byte / 64-hex scalar)', ), ) return '0x' + v.replace(/^0x/, '').toLowerCase() } /** Read `RECLAIM_PRIVATE_KEY` from a dotenv file (simple KEY=VALUE parse). */ function readDotenvKey(path: string): string | undefined { if(!existsSync(path)) { return undefined } for(const line of readFileSync(path, 'utf8').split('\n')) { const m = line.match(/^\s*RECLAIM_PRIVATE_KEY\s*=\s*(.+?)\s*$/) if(m) { return m[1] } } return undefined } /** The explicit raw-hex key-file path, if `RECLAIM_PRIVATE_KEY_FILE` is set. */ function ethKeyFilePath( projectDir = findProjectRoot(process.cwd()), ): string | undefined { const p = privateKeyFile() if(!p) { return undefined } return isAbsolute(p) ? p : join(projectDir, p) } function resolved( privateKey: string, source: EthKeySource, path?: string, ): ResolvedEthKey { return { privateKey, address: deriveFromPrivateKey(privateKey).address, source, ...(path ? { path } : {}), } } /** * Resolve the eth proof-owner key without prompting. Returns `undefined` when * no key is configured anywhere — the caller decides whether to generate or * ask for one (see `resolve_owner_key`). */ export function resolveEthKey( projectDir = findProjectRoot(process.cwd()), ): ResolvedEthKey | undefined { const env = privateKey() if(env?.trim()) { return resolved(normalizeEthHex(env), 'env') } const envPath = join(projectDir, ENV_FILE) const fromDotenv = readDotenvKey(envPath) if(fromDotenv) { return resolved(normalizeEthHex(fromDotenv), 'dotenv', envPath) } const keyFile = ethKeyFilePath(projectDir) if(keyFile && existsSync(keyFile)) { return resolved( normalizeEthHex(readFileSync(keyFile, 'utf8')), 'file', keyFile, ) } return undefined } /** Append `entry` to the project `.gitignore` if not already present. */ function ensureGitignored(projectDir: string, entry: string) { const path = join(projectDir, '.gitignore') const current = existsSync(path) ? readFileSync(path, 'utf8') : '' if(current.split('\n').some((l) => l.trim() === entry)) { return } const prefix = current && !current.endsWith('\n') ? '\n' : '' writeFileSync(path, current + prefix + entry + '\n') } /** * Persist a raw eth private key to the project `.env` as * `RECLAIM_PRIVATE_KEY=0x…` (upsert; mode 0600; `.env` added to `.gitignore`). * Used by `issue_credentials` (generate) and `import_credentials` (supplied * hex). Returns the derived address and the `.env` path. */ export function writeEthKey( privateKeyHex: string, projectDir = findProjectRoot(process.cwd()), ): { path: string, address: string } { const hex = normalizeEthHex(privateKeyHex) const envPath = join(projectDir, ENV_FILE) const line = `${ENV_VAR}=${hex}` const existing = existsSync(envPath) ? readFileSync(envPath, 'utf8') : '' const re = new RegExp(`^\\s*${ENV_VAR}\\s*=.*$`, 'm') let next: string if(re.test(existing)) { next = existing.replace(re, line) } else { const prefix = existing && !existing.endsWith('\n') ? '\n' : '' next = existing + prefix + line + '\n' } writeFileSync(envPath, next, { mode: 0o600 }) ensureGitignored(projectDir, ENV_FILE) return { path: envPath, address: deriveFromPrivateKey(hex).address } }