import { Either, getRequiredEnvVars, getStdout, getStdoutMany, type IEither, type ITraceable, type LogMetricTraceSupplier, Metric, TraceUtil, } from '@emprespresso/pengueno'; import { randomUUID } from 'node:crypto'; import { mkdirSync } from 'node:fs'; import path from 'node:path'; // -- -- export interface SecretItem { name: string; } export interface LoginItem extends SecretItem { login: { username: string; password: string; }; } export interface SecureNote extends SecretItem { notes: string; } export interface IVault { unlock: (client: TClient) => Promise>; lock: (client: TClient, key: TKey) => Promise>; fetchSecret: (client: TClient, key: TKey, item: TItemId) => Promise>; } // -- -- // -- -- type TClient = ITraceable; export type BitwardenKey = { BW_SESSION: string; BITWARDENCLI_APPDATA_DIR: string; }; type TItemId = string; export class Bitwarden implements IVault { constructor(private readonly config: BitwardenConfig) {} public unlock(client: TClient) { return client .move(this.config) .flatMap(TraceUtil.withMetricTrace(Bitwarden.loginMetric)) .map((tConfig) => Either.fromFailable< Error, { config: BitwardenConfig; key: Pick } >(() => { const sessionPath = path.join(this.config.sessionBaseDirectory, randomUUID()); mkdirSync(sessionPath, { recursive: true }); return { config: tConfig.get(), key: { BITWARDENCLI_APPDATA_DIR: sessionPath } }; }), ) .map((tEitherConfig) => tEitherConfig .get() .flatMapAsync(({ config: { server }, key }) => getStdoutMany( tEitherConfig.move([ `bw config server ${server}`, `bw login --apikey --quiet`, `bw unlock --passwordenv BW_PASSWORD --raw`, ]), { env: key }, ).then((res) => res.mapRight((out) => ({ ...key, BW_SESSION: out.at(-1)! }))), ), ) .peek(TraceUtil.promiseify(TraceUtil.traceResultingEither(Bitwarden.loginMetric))) .get(); } public fetchSecret( client: TClient, key: BitwardenKey, item: string, ): Promise> { return client .move(key) .flatMap(TraceUtil.withMetricTrace(Bitwarden.fetchSecretMetric)) .peek((tSession) => tSession.trace.trace(`looking for your secret ${item} (⑅˘꒳˘)`)) .flatMap((tSession) => tSession.move(`bw list items --search ${item}`).map((listCmd) => getStdout(listCmd, { env: key })), ) .map( TraceUtil.promiseify((tEitherItemsJson) => tEitherItemsJson .get() .flatMap( (itemsJson): IEither> => Either.fromFailable(() => JSON.parse(itemsJson)), ) .flatMap((itemsList): IEither => { const secret = itemsList.find(({ name }) => name === item); if (!secret) { return Either.left(new Error(`couldn't find the item ${item} (。•́︿•̀。)`)); } return Either.right(secret); }), ), ) .flatMapAsync(TraceUtil.promiseify(TraceUtil.traceResultingEither(Bitwarden.fetchSecretMetric))) .get(); } public lock(client: TClient, key: BitwardenKey) { return client .move(key) .flatMap(TraceUtil.withMetricTrace(Bitwarden.lockVaultMetric)) .peek((tSession) => tSession.trace.trace(`taking care of locking the vault :3`)) .flatMap((tSession) => tSession.move('bw lock && bw logout').map((lockCmd) => getStdout(lockCmd, { env: key })), ) .peek(TraceUtil.promiseify(TraceUtil.traceResultingEither(Bitwarden.lockVaultMetric))) .peek( TraceUtil.promiseify((tEitherWithLocked) => tEitherWithLocked .get() .mapRight(() => tEitherWithLocked.trace.trace('all locked up and secure now~ (。•̀ᴗ-)✧')), ), ) .map(TraceUtil.promiseify((e) => e.get().mapRight(() => key))) .get(); } public static getConfigFromEnvironment(sessionBaseDirectory = '/tmp/secret'): IEither { return getRequiredEnvVars(['BW_SERVER', 'BW_CLIENTSECRET', 'BW_CLIENTID', 'BW_PASSWORD']).mapRight( ({ BW_SERVER, BW_CLIENTSECRET, BW_CLIENTID }) => ({ sessionBaseDirectory, clientId: BW_CLIENTID, secret: BW_CLIENTSECRET, server: BW_SERVER, }), ); } private static loginMetric = Metric.fromName('Bitwarden.login').asResult(); private static unlockVaultMetric = Metric.fromName('Bitwarden.unlockVault').asResult(); private static fetchSecretMetric = Metric.fromName('Bitwarden.fetchSecret').asResult(); private static lockVaultMetric = Metric.fromName('Bitwarden.lock').asResult(); } export interface BitwardenConfig { sessionBaseDirectory: string; server: string; secret: string; clientId: string; } // -- --