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;
}
// -- --