236 lines
7.8 KiB
TypeScript
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;
|
|
}
|
|
}
|