Files
Elydora-Node-SDK/src/cli-secrets.ts
T

124 lines
3.4 KiB
TypeScript

import fsp from 'node:fs/promises';
import readline from 'node:readline';
import { Writable } from 'node:stream';
const MAX_SECRET_FILE_BYTES = 64 * 1024;
interface SecretSources {
readonly privateKeyFile?: string;
readonly tokenFile?: string;
}
interface SecretTerminal {
readonly interactive: boolean;
readHidden(prompt: string): Promise<string>;
}
export interface InstallSecrets {
readonly privateKey: string;
readonly token?: string;
}
function parseSingleLineSecret(raw: string, label: string, allowEmpty: boolean): string {
const value = raw.endsWith('\r\n')
? raw.slice(0, -2)
: raw.endsWith('\n')
? raw.slice(0, -1)
: raw;
if (!allowEmpty && value.length === 0) {
throw new Error(`${label} is empty`);
}
if (value.includes('\r') || value.includes('\n') || value.includes('\0')) {
throw new Error(`${label} must contain exactly one line`);
}
return value;
}
async function readSecretFile(filePath: string, label: string): Promise<string> {
const stats = await fsp.lstat(filePath);
if (!stats.isFile()) {
throw new Error(`${label} file is not a regular file: ${filePath}`);
}
if (stats.size > MAX_SECRET_FILE_BYTES) {
throw new Error(`${label} file exceeds ${MAX_SECRET_FILE_BYTES} bytes: ${filePath}`);
}
if (process.platform !== 'win32' && (stats.mode & 0o077) !== 0) {
throw new Error(`${label} file must be accessible only by its owner: ${filePath}`);
}
const raw = await fsp.readFile(filePath, 'utf-8');
return parseSingleLineSecret(raw, label, false);
}
async function readHidden(prompt: string): Promise<string> {
if (!process.stdin.isTTY || !process.stderr.isTTY) {
throw new Error('hidden input requires an interactive terminal');
}
let muted = false;
const output = new Writable({
write(chunk, encoding, callback) {
if (!muted) process.stderr.write(chunk, encoding);
callback();
},
});
const terminal = readline.createInterface({
input: process.stdin,
output,
terminal: true,
historySize: 0,
});
process.stderr.write(prompt);
muted = true;
try {
return await new Promise<string>((resolve, reject) => {
terminal.once('SIGINT', () => reject(new Error('secret input cancelled')));
terminal.question('', resolve);
});
} finally {
muted = false;
terminal.close();
process.stderr.write('\n');
}
}
const defaultTerminal: SecretTerminal = {
interactive: Boolean(process.stdin.isTTY && process.stderr.isTTY),
readHidden,
};
export async function resolveInstallSecrets(
sources: SecretSources,
terminal: SecretTerminal = defaultTerminal,
): Promise<InstallSecrets> {
let privateKey: string;
if (sources.privateKeyFile) {
privateKey = await readSecretFile(sources.privateKeyFile, 'private key');
} else if (terminal.interactive) {
privateKey = parseSingleLineSecret(
await terminal.readHidden('Private key: '),
'private key',
false,
);
} else {
throw new Error(
'private key input requires an interactive terminal or --private_key_file <path>',
);
}
let token: string | undefined;
if (sources.tokenFile) {
token = await readSecretFile(sources.tokenFile, 'API token');
} else if (terminal.interactive) {
token = parseSingleLineSecret(
await terminal.readHidden('API token (optional): '),
'API token',
true,
) || undefined;
}
return { privateKey, token };
}