|
| 1 | +import { promises as fs } from 'node:fs'; |
| 2 | +import { dirname } from 'node:path'; |
| 3 | +import { createKeyPairSignerFromBytes, createKeyPairSignerFromPrivateKeyBytes, type KeyPairSigner } from '@solana/kit'; |
| 4 | +import bs58 from 'bs58'; |
| 5 | + |
| 6 | +type PersistableKeypair = Readonly<{ |
| 7 | + base58SecretKey: string; |
| 8 | + publicKey: Uint8Array; |
| 9 | + secretKey: Uint8Array; |
| 10 | + signer: KeyPairSigner; |
| 11 | +}>; |
| 12 | + |
| 13 | +type KeypairLoadOptions = Readonly<{ |
| 14 | + extractable?: boolean; |
| 15 | +}>; |
| 16 | + |
| 17 | +function concatBytes(first: Uint8Array, second: Uint8Array): Uint8Array { |
| 18 | + const result = new Uint8Array(first.length + second.length); |
| 19 | + result.set(first, 0); |
| 20 | + result.set(second, first.length); |
| 21 | + return result; |
| 22 | +} |
| 23 | + |
| 24 | +async function exportPublicKeyBytes(signer: KeyPairSigner): Promise<Uint8Array> { |
| 25 | + const raw = await crypto.subtle.exportKey('raw', signer.keyPair.publicKey); |
| 26 | + return new Uint8Array(raw); |
| 27 | +} |
| 28 | + |
| 29 | +async function loadSignerFromBytes(bytes: Uint8Array, extractable: boolean): Promise<PersistableKeypair> { |
| 30 | + const normalized = new Uint8Array(bytes); |
| 31 | + if (normalized.length !== 32 && normalized.length !== 64) { |
| 32 | + throw new Error('Expected 32-byte private key or 64-byte secret key.'); |
| 33 | + } |
| 34 | + const signer = |
| 35 | + normalized.length === 32 |
| 36 | + ? await createKeyPairSignerFromPrivateKeyBytes(normalized, extractable) |
| 37 | + : await createKeyPairSignerFromBytes(normalized, extractable); |
| 38 | + const publicKey = await exportPublicKeyBytes(signer); |
| 39 | + const secretKey = normalized.length === 64 ? normalized : concatBytes(normalized, publicKey); |
| 40 | + return { |
| 41 | + base58SecretKey: bs58.encode(secretKey), |
| 42 | + publicKey, |
| 43 | + secretKey, |
| 44 | + signer, |
| 45 | + }; |
| 46 | +} |
| 47 | + |
| 48 | +function parseKeyMaterial(raw: string): Uint8Array { |
| 49 | + const trimmed = raw.trim(); |
| 50 | + try { |
| 51 | + const parsed = JSON.parse(trimmed); |
| 52 | + if (Array.isArray(parsed)) { |
| 53 | + return new Uint8Array(parsed); |
| 54 | + } |
| 55 | + } catch { |
| 56 | + // fall through to base58 parsing below |
| 57 | + } |
| 58 | + try { |
| 59 | + return bs58.decode(trimmed); |
| 60 | + } catch { |
| 61 | + throw new Error('Could not parse key material. Expected a base58 string or JSON array.'); |
| 62 | + } |
| 63 | +} |
| 64 | + |
| 65 | +async function ensureParentDir(path: string): Promise<void> { |
| 66 | + await fs.mkdir(dirname(path), { recursive: true }); |
| 67 | +} |
| 68 | + |
| 69 | +export async function generateKeypair(options: KeypairLoadOptions = {}): Promise<PersistableKeypair> { |
| 70 | + const byteCount = 32; |
| 71 | + const seed = new Uint8Array(byteCount); |
| 72 | + crypto.getRandomValues(seed); |
| 73 | + return loadSignerFromBytes(seed, options.extractable ?? true); |
| 74 | +} |
| 75 | + |
| 76 | +export async function loadKeypairFromBytes( |
| 77 | + bytes: Uint8Array, |
| 78 | + options: KeypairLoadOptions = {}, |
| 79 | +): Promise<PersistableKeypair> { |
| 80 | + return loadSignerFromBytes(bytes, options.extractable ?? true); |
| 81 | +} |
| 82 | + |
| 83 | +export async function loadKeypairFromBase58( |
| 84 | + secret: string, |
| 85 | + options: KeypairLoadOptions = {}, |
| 86 | +): Promise<PersistableKeypair> { |
| 87 | + return loadSignerFromBytes(bs58.decode(secret), options.extractable ?? true); |
| 88 | +} |
| 89 | + |
| 90 | +export async function loadKeypairFromEnv( |
| 91 | + variableName: string, |
| 92 | + options: KeypairLoadOptions = {}, |
| 93 | +): Promise<PersistableKeypair> { |
| 94 | + const value = process.env[variableName]; |
| 95 | + if (!value) { |
| 96 | + throw new Error(`Environment variable ${variableName} is not set.`); |
| 97 | + } |
| 98 | + return loadSignerFromBytes(parseKeyMaterial(value), options.extractable ?? true); |
| 99 | +} |
| 100 | + |
| 101 | +export async function loadKeypairFromFile( |
| 102 | + filePath: string, |
| 103 | + options: KeypairLoadOptions = {}, |
| 104 | +): Promise<PersistableKeypair> { |
| 105 | + const contents = await fs.readFile(filePath, 'utf8'); |
| 106 | + return loadSignerFromBytes(parseKeyMaterial(contents), options.extractable ?? true); |
| 107 | +} |
| 108 | + |
| 109 | +export type SaveFormat = 'json' | 'base58'; |
| 110 | + |
| 111 | +export type SaveKeypairInput = Readonly<{ |
| 112 | + format?: SaveFormat; |
| 113 | + keypair: Pick<PersistableKeypair, 'secretKey'> | PersistableKeypair; |
| 114 | +}>; |
| 115 | + |
| 116 | +export async function saveKeypairToFile(filePath: string, input: SaveKeypairInput): Promise<void> { |
| 117 | + const secretKey = new Uint8Array(input.keypair.secretKey); |
| 118 | + const format = input.format ?? 'json'; |
| 119 | + const encoded = format === 'base58' ? bs58.encode(secretKey) : JSON.stringify(Array.from(secretKey)); |
| 120 | + await ensureParentDir(filePath); |
| 121 | + await fs.writeFile(filePath, `${encoded}\n`, { encoding: 'utf8', mode: 0o600 }); |
| 122 | +} |
| 123 | + |
| 124 | +export async function saveKeypairToEnvFile( |
| 125 | + envPath: string, |
| 126 | + variableName: string, |
| 127 | + input: SaveKeypairInput, |
| 128 | +): Promise<void> { |
| 129 | + const secretKey = new Uint8Array(input.keypair.secretKey); |
| 130 | + const format = input.format ?? 'base58'; |
| 131 | + const encoded = format === 'base58' ? bs58.encode(secretKey) : JSON.stringify(Array.from(secretKey)); |
| 132 | + await ensureParentDir(envPath); |
| 133 | + await fs.appendFile(envPath, `${variableName}=${encoded}\n`, { encoding: 'utf8', mode: 0o600 }); |
| 134 | +} |
| 135 | + |
| 136 | +export type ServerKeypair = PersistableKeypair; |
0 commit comments