Files
stack/packages/mosaic/src/credentials/tea-login-store.ts
T

236 lines
7.8 KiB
TypeScript

import { randomUUID, timingSafeEqual } from 'node:crypto';
import { open, rename, unlink } from 'node:fs/promises';
import { dirname } from 'node:path';
import { parse, stringify } from 'yaml';
import { z } from 'zod';
import { ensureManagedDirectory, readRegularFileSecure } from '../fleet/secure-file.js';
import type { ResolvedCredential } from './credential-provider.dto.js';
const SAFE_NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
export class TeaLoginStoreError extends Error {
constructor(
public readonly code: string,
message: string,
) {
super(`Tea login store rejected: code=${code} ${message}`);
this.name = 'TeaLoginStoreError';
}
}
interface TeaLoginRecord {
readonly name: string;
readonly url: string;
readonly token: string;
readonly user: string;
readonly default: boolean;
}
interface TeaConfig {
readonly logins: TeaLoginRecord[];
readonly [key: string]: unknown;
}
const loginSchema = z
.object({
name: z.string().regex(SAFE_NAME),
url: z.string().url(),
token: z.string().min(1),
user: z.string().regex(SAFE_NAME),
default: z.boolean().default(false),
})
.passthrough();
const configSchema = z.object({ logins: z.array(loginSchema).default([]) }).passthrough();
async function acquireLock(path: string): Promise<Awaited<ReturnType<typeof open>>> {
for (let attempt = 0; attempt < 500; attempt += 1) {
try {
return await open(path, 'wx', 0o600);
} catch (error: unknown) {
if (!(error instanceof Error && 'code' in error && error.code === 'EEXIST')) throw error;
await new Promise<void>((resolve): void => {
setTimeout(resolve, 10);
});
}
}
throw new TeaLoginStoreError(
'conflicting-credential-mutation',
'Tea configuration lock did not become available',
);
}
function loginName(identity: string, host: string): string {
return `${identity}--${host}`;
}
function assertPrivate(snapshot: { readonly mode: number; readonly uid: number }): void {
if ((snapshot.mode & 0o077) !== 0 || snapshot.uid !== process.getuid?.()) {
throw new TeaLoginStoreError('tea-config-insecure', 'Tea config is not private');
}
}
function missing(error: unknown): boolean {
return error instanceof Error && 'code' in error && error.code === 'ENOENT';
}
export class TeaLoginStore {
constructor(private readonly configPath: string) {}
async put(identity: string, host: string, secret: Uint8Array): Promise<void> {
if (!SAFE_NAME.test(identity) || !/^[a-z0-9][a-z0-9.-]*$/.test(host)) {
throw new TeaLoginStoreError('invalid-input', 'identity or host is outside the grammar');
}
const directory = dirname(this.configPath);
ensureManagedDirectory(directory, directory);
const lockPath = `${this.configPath}.lock`;
const lock = await acquireLock(lockPath);
try {
let current: TeaConfig = { logins: [] };
try {
const snapshot = readRegularFileSecure(this.configPath, {
root: directory,
maxBytes: 1024 * 1024,
});
assertPrivate(snapshot);
const decoded = configSchema.safeParse(parse(snapshot.content.toString('utf8')));
if (!decoded.success) {
throw new TeaLoginStoreError('tea-config-invalid', 'Tea config failed schema validation');
}
current = decoded.data;
} catch (error: unknown) {
if (!missing(error)) throw error;
}
const token = Buffer.from(secret).toString('utf8');
const record: TeaLoginRecord = {
name: loginName(identity, host),
url: `https://${host}`,
token,
user: identity,
default: false,
};
const logins = current.logins.filter(
(login): boolean =>
!(login.name === loginName(identity, host) && login.url === `https://${host}`),
);
logins.push(record);
const temp = `${this.configPath}.${randomUUID()}.tmp`;
const handle = await open(temp, 'wx', 0o600);
try {
await handle.writeFile(stringify({ ...current, logins }), 'utf8');
await handle.sync();
} finally {
await handle.close();
}
await rename(temp, this.configPath);
} finally {
await lock.close();
await unlink(lockPath).catch((): void => undefined);
}
}
resolve(identity: string, estate: string, host: string): ResolvedCredential | undefined {
const directory = dirname(this.configPath);
let snapshot;
try {
snapshot = readRegularFileSecure(this.configPath, {
root: directory,
maxBytes: 1024 * 1024,
});
} catch (error: unknown) {
if (missing(error)) return undefined;
throw error;
}
assertPrivate(snapshot);
const decoded = configSchema.safeParse(parse(snapshot.content.toString('utf8')));
if (!decoded.success) return undefined;
const matches = decoded.data.logins.filter(
(login): boolean =>
login.name === loginName(identity, host) &&
login.url === `https://${host}` &&
login.user === identity,
);
if (matches.length !== 1 || matches[0] === undefined) return undefined;
return Object.freeze({
identity,
estate,
host,
resolutionId: randomUUID(),
secret: new TextEncoder().encode(matches[0].token),
});
}
matchesSecret(identity: string, host: string, secret: Uint8Array): boolean {
const resolved = this.resolve(identity, 'binding-check', host);
if (resolved === undefined || resolved.secret.byteLength !== secret.byteLength) return false;
return timingSafeEqual(Buffer.from(resolved.secret), Buffer.from(secret));
}
async remove(identity: string, host: string): Promise<void> {
const directory = dirname(this.configPath);
const lockPath = `${this.configPath}.lock`;
const lock = await acquireLock(lockPath);
try {
const snapshot = readRegularFileSecure(this.configPath, {
root: directory,
maxBytes: 1024 * 1024,
});
assertPrivate(snapshot);
const decoded = configSchema.safeParse(parse(snapshot.content.toString('utf8')));
if (!decoded.success) {
throw new TeaLoginStoreError('tea-config-invalid', 'Tea config failed schema validation');
}
const logins = decoded.data.logins.filter(
(login): boolean =>
!(login.name === loginName(identity, host) && login.url === `https://${host}`),
);
const temp = `${this.configPath}.${randomUUID()}.tmp`;
const handle = await open(temp, 'wx', 0o600);
try {
await handle.writeFile(stringify({ ...decoded.data, logins }), 'utf8');
await handle.sync();
} finally {
await handle.close();
}
await rename(temp, this.configPath);
} finally {
await lock.close();
await unlink(lockPath).catch((): void => undefined);
}
}
readBack(
identity: string,
host: string,
): { readonly name: string; readonly host: string } | undefined {
const directory = dirname(this.configPath);
let snapshot;
try {
snapshot = readRegularFileSecure(this.configPath, { root: directory, maxBytes: 1024 * 1024 });
} catch (error: unknown) {
if (missing(error)) return undefined;
throw error;
}
assertPrivate(snapshot);
const decoded: unknown = parse(snapshot.content.toString('utf8'));
if (
typeof decoded !== 'object' ||
decoded === null ||
!('logins' in decoded) ||
!Array.isArray(decoded.logins)
)
return undefined;
const matches = decoded.logins.filter((value: unknown): value is TeaLoginRecord => {
if (typeof value !== 'object' || value === null) return false;
return (
'name' in value &&
value.name === loginName(identity, host) &&
'url' in value &&
value.url === `https://${host}` &&
'user' in value &&
value.user === identity
);
});
return matches.length === 1 ? { name: loginName(identity, host), host } : undefined;
}
}