|
| 1 | +import { generateKeyPairSync } from "node:crypto" |
| 2 | +import { createWalletFromEnv, type WalletInfo } from "@opensea/wallet-adapters" |
| 3 | +import { Command } from "commander" |
| 4 | +import type { OutputFormat } from "../output.js" |
| 5 | +import { formatOutput } from "../output.js" |
| 6 | + |
| 7 | +const PRIVY_API_BASE = "https://api.privy.io" |
| 8 | + |
| 9 | +/** |
| 10 | + * `opensea wallet info` reports the security posture of the active |
| 11 | + * wallet adapter — what credential is in env, what hardening is in |
| 12 | + * place, what is missing. Read-only. Provider-aware. |
| 13 | + * |
| 14 | + * Warnings go to stderr; data goes to stdout. Exit code is 0 on |
| 15 | + * success regardless of warnings; nonzero only on auth/network |
| 16 | + * failure. |
| 17 | + */ |
| 18 | +export function walletCommand(getFormat: () => OutputFormat): Command { |
| 19 | + const cmd = new Command("wallet").description( |
| 20 | + "Inspect the active wallet adapter's security posture", |
| 21 | + ) |
| 22 | + |
| 23 | + cmd |
| 24 | + .command("generate-auth-key") |
| 25 | + .description( |
| 26 | + "Generate a P-256 keypair for use as a Privy authorization key. " + |
| 27 | + "Pure-local — no API calls. The private key is for the agent " + |
| 28 | + "(PRIVY_AUTH_SIGNING_KEY) or for off-machine storage if registering " + |
| 29 | + "as owner_id; the public key is what you register with Privy.", |
| 30 | + ) |
| 31 | + .action(() => { |
| 32 | + const { publicKey, privateKey } = generateKeyPairSync("ec", { |
| 33 | + namedCurve: "P-256", |
| 34 | + }) |
| 35 | + const privatePkcs8 = privateKey |
| 36 | + .export({ type: "pkcs8", format: "der" }) |
| 37 | + .toString("base64") |
| 38 | + const publicSpki = publicKey |
| 39 | + .export({ type: "spki", format: "der" }) |
| 40 | + .toString("base64") |
| 41 | + console.log( |
| 42 | + formatOutput( |
| 43 | + { |
| 44 | + privateKey: privatePkcs8, |
| 45 | + publicKey: publicSpki, |
| 46 | + format: "PKCS8 (private) / SPKI (public), base64-encoded P-256", |
| 47 | + usage: |
| 48 | + "Register publicKey with Privy as additional_signer or owner. " + |
| 49 | + "Set privateKey as PRIVY_AUTH_SIGNING_KEY (additional_signer) " + |
| 50 | + "OR keep it OFF the agent host (owner). Never put both keys " + |
| 51 | + "on the agent.", |
| 52 | + }, |
| 53 | + getFormat(), |
| 54 | + ), |
| 55 | + ) |
| 56 | + }) |
| 57 | + |
| 58 | + cmd |
| 59 | + .command("create") |
| 60 | + .description( |
| 61 | + "Create a new Privy server wallet. Privy-only. Creates a NEW " + |
| 62 | + "resource — cannot touch existing wallets. New wallet has no " + |
| 63 | + "funds and no policy until separately configured. Reads " + |
| 64 | + "PRIVY_APP_ID and PRIVY_APP_SECRET from env.", |
| 65 | + ) |
| 66 | + .option( |
| 67 | + "--chain-type <type>", |
| 68 | + "Chain type for the wallet (default: ethereum)", |
| 69 | + "ethereum", |
| 70 | + ) |
| 71 | + .option( |
| 72 | + "--owner-public-key <base64>", |
| 73 | + "Optional SPKI base64-encoded P-256 public key to register as " + |
| 74 | + "owner. Recommended: pass the public key from `opensea wallet " + |
| 75 | + "generate-auth-key` (whose private half you keep OFF this " + |
| 76 | + "machine). Without an owner_id, env credentials can rewrite the " + |
| 77 | + "wallet's policy unilaterally.", |
| 78 | + ) |
| 79 | + .action(async (options: { chainType: string; ownerPublicKey?: string }) => { |
| 80 | + const appId = process.env.PRIVY_APP_ID |
| 81 | + const appSecret = process.env.PRIVY_APP_SECRET |
| 82 | + if (!appId || !appSecret) { |
| 83 | + console.error( |
| 84 | + "PRIVY_APP_ID and PRIVY_APP_SECRET must be set in env to create " + |
| 85 | + "a Privy server wallet.", |
| 86 | + ) |
| 87 | + process.exit(1) |
| 88 | + return |
| 89 | + } |
| 90 | + try { |
| 91 | + const baseUrl = process.env.PRIVY_API_BASE_URL ?? PRIVY_API_BASE |
| 92 | + const credentials = Buffer.from(`${appId}:${appSecret}`).toString( |
| 93 | + "base64", |
| 94 | + ) |
| 95 | + const body: Record<string, unknown> = { chain_type: options.chainType } |
| 96 | + if (options.ownerPublicKey) { |
| 97 | + body.owner = { public_key: options.ownerPublicKey } |
| 98 | + } |
| 99 | + const response = await fetch(`${baseUrl}/v1/wallets`, { |
| 100 | + method: "POST", |
| 101 | + headers: { |
| 102 | + Authorization: `Basic ${credentials}`, |
| 103 | + "privy-app-id": appId, |
| 104 | + "Content-Type": "application/json", |
| 105 | + }, |
| 106 | + body: JSON.stringify(body), |
| 107 | + }) |
| 108 | + if (!response.ok) { |
| 109 | + const errorBody = await response.text() |
| 110 | + const hint = |
| 111 | + response.status === 401 && |
| 112 | + errorBody.includes("Invalid app ID or app secret") |
| 113 | + ? " (hint: if you tested via curl, use 'printf %s \"$id:$secret\" | base64' — 'echo' adds a trailing newline that breaks basic auth)" |
| 114 | + : "" |
| 115 | + console.error( |
| 116 | + JSON.stringify( |
| 117 | + { |
| 118 | + error: "Wallet create failed", |
| 119 | + status: response.status, |
| 120 | + body: errorBody + hint, |
| 121 | + }, |
| 122 | + null, |
| 123 | + 2, |
| 124 | + ), |
| 125 | + ) |
| 126 | + process.exit(1) |
| 127 | + } |
| 128 | + const data = (await response.json()) as { |
| 129 | + id: string |
| 130 | + address: string |
| 131 | + chain_type: string |
| 132 | + owner_id?: string | null |
| 133 | + } |
| 134 | + if (!options.ownerPublicKey) { |
| 135 | + console.error( |
| 136 | + "WARNING: created without --owner-public-key. The wallet has " + |
| 137 | + "no owner_id and the credentials in env can rewrite its " + |
| 138 | + "policy unilaterally. Register an owner before applying a " + |
| 139 | + "policy or funding the wallet (see " + |
| 140 | + "https://github.com/ProjectOpenSea/opensea-skill/blob/main/docs/policy-administration.md).", |
| 141 | + ) |
| 142 | + } |
| 143 | + console.log( |
| 144 | + formatOutput( |
| 145 | + { |
| 146 | + id: data.id, |
| 147 | + address: data.address, |
| 148 | + chainType: data.chain_type, |
| 149 | + ownerId: data.owner_id ?? null, |
| 150 | + nextSteps: options.ownerPublicKey |
| 151 | + ? "Set PRIVY_WALLET_ID to this id, apply a policy via " + |
| 152 | + "https://github.com/ProjectOpenSea/opensea-skill/blob/main/docs/policy-administration.md, fund the wallet, then " + |
| 153 | + "run `opensea wallet info`." |
| 154 | + : "Register an owner_id BEFORE funding. See " + |
| 155 | + "https://github.com/ProjectOpenSea/opensea-skill/blob/main/docs/policy-administration.md and opensea-wallet/references/wallet-setup.md.", |
| 156 | + }, |
| 157 | + getFormat(), |
| 158 | + ), |
| 159 | + ) |
| 160 | + } catch (error) { |
| 161 | + console.error( |
| 162 | + JSON.stringify( |
| 163 | + { error: "Wallet error", message: (error as Error).message }, |
| 164 | + null, |
| 165 | + 2, |
| 166 | + ), |
| 167 | + ) |
| 168 | + process.exit(1) |
| 169 | + } |
| 170 | + }) |
| 171 | + |
| 172 | + cmd |
| 173 | + .command("info") |
| 174 | + .description( |
| 175 | + "Show wallet address, policy/role posture, and hardening warnings", |
| 176 | + ) |
| 177 | + .action(async () => { |
| 178 | + try { |
| 179 | + const adapter = createWalletFromEnv() |
| 180 | + if (!adapter.getWalletInfo) { |
| 181 | + console.error( |
| 182 | + `Provider ${adapter.name} does not expose wallet info. ` + |
| 183 | + "This is expected for the private-key adapter.", |
| 184 | + ) |
| 185 | + const address = await adapter.getAddress() |
| 186 | + console.log( |
| 187 | + formatOutput({ provider: adapter.name, address }, getFormat()), |
| 188 | + ) |
| 189 | + return |
| 190 | + } |
| 191 | + const info = await adapter.getWalletInfo() |
| 192 | + for (const warning of warningsForInfo(info)) { |
| 193 | + console.error(`WARNING: ${warning}`) |
| 194 | + } |
| 195 | + console.log(formatOutput(info, getFormat())) |
| 196 | + } catch (error) { |
| 197 | + console.error( |
| 198 | + JSON.stringify( |
| 199 | + { |
| 200 | + error: "Wallet error", |
| 201 | + message: (error as Error).message, |
| 202 | + }, |
| 203 | + null, |
| 204 | + 2, |
| 205 | + ), |
| 206 | + ) |
| 207 | + process.exit(1) |
| 208 | + } |
| 209 | + }) |
| 210 | + |
| 211 | + return cmd |
| 212 | +} |
| 213 | + |
| 214 | +/** |
| 215 | + * Translate provider-specific posture flags into human-readable |
| 216 | + * warnings. Each warning maps to a documented hardening step in |
| 217 | + * `packages/skill/opensea-wallet/references/wallet-setup.md` and |
| 218 | + * `https://github.com/ProjectOpenSea/opensea-skill/blob/main/docs/policy-administration.md`. |
| 219 | + */ |
| 220 | +function warningsForInfo(info: WalletInfo): string[] { |
| 221 | + switch (info.provider) { |
| 222 | + case "privy": { |
| 223 | + const out: string[] = [] |
| 224 | + if (!info.ownerEnforcesAuthKey) { |
| 225 | + out.push( |
| 226 | + "Wallet has no owner_id — the credentials in env can rewrite " + |
| 227 | + "policy unilaterally. Register an authorization key on the " + |
| 228 | + "wallet (see https://github.com/ProjectOpenSea/opensea-skill/blob/main/docs/policy-administration.md and " + |
| 229 | + "opensea-wallet/references/wallet-setup.md, Privy section).", |
| 230 | + ) |
| 231 | + } |
| 232 | + if (info.policyIds.length === 0) { |
| 233 | + out.push( |
| 234 | + "Wallet has no policy_ids — there is no on-chain spend " + |
| 235 | + "enforcement. Apply a per-tx cap policy (see " + |
| 236 | + "opensea-wallet/references/wallet-policies.md).", |
| 237 | + ) |
| 238 | + } |
| 239 | + return out |
| 240 | + } |
| 241 | + case "turnkey": { |
| 242 | + const out: string[] = [] |
| 243 | + if (info.isRootUser) { |
| 244 | + out.push( |
| 245 | + `API user "${info.username || info.userId}" is in the root ` + |
| 246 | + "quorum. Root users bypass Turnkey's policy engine entirely. " + |
| 247 | + "Create a non-root signer-only API user instead (see " + |
| 248 | + "opensea-wallet/references/wallet-setup.md, Turnkey section).", |
| 249 | + ) |
| 250 | + } |
| 251 | + return out |
| 252 | + } |
| 253 | + case "fireblocks": |
| 254 | + return [ |
| 255 | + "Fireblocks does not expose API-user role via API. Confirm at " + |
| 256 | + "console.fireblocks.io that this key has the `Signer` role " + |
| 257 | + "and not `Admin` or any broader role. Re-confirm whenever " + |
| 258 | + "the key is rotated.", |
| 259 | + ] |
| 260 | + case "bankr": |
| 261 | + return [ |
| 262 | + "Bankr does not expose API-key scope flags via API. Confirm at " + |
| 263 | + "bankr.bot/api that this key has appropriate readOnly / " + |
| 264 | + "allowedRecipients / allowedIps / daily-limit settings. " + |
| 265 | + "Re-confirm whenever the key is rotated.", |
| 266 | + ] |
| 267 | + default: { |
| 268 | + const _exhaustive: never = info |
| 269 | + return _exhaustive |
| 270 | + } |
| 271 | + } |
| 272 | +} |
0 commit comments