feat: store rdp, vnc and telnet logins in a generic per-protocol table

This commit is contained in:
LukeGus committed 2026-09-29 12:33:09 -05:00
1 parent e0c30a4e6f
commit bba06f7ff6
107 files changed
+18750 -1224

No files matched your search

-3
View File
@@ -20,9 +20,6 @@ const SECRET_FIELDS = [
"keyPassword",
"sudoPassword",
"socks5Password",
"rdpPassword",
"vncPassword",
"telnetPassword",
] as const;
const REFERENCE_PATTERN = /^([a-z][a-z0-9+.-]*):\/\//i;
@@ -0,0 +1,660 @@
/**
* Per-host logins for the protocols plugins declare (contributes.protocols).
*
* Core stores them in host_protocol_auth, encrypted with the host owner's
* data key, and knows each protocol only by its declaration: which extra
* fields a login carries and which of those are secret. Writes, the host
* payload, sharing, export and sync all go through here, so a new protocol
* needs no core change.
*/
import {
createCurrentHostProtocolAuthRepository,
createCurrentSharedHostAuthOverrideRepository,
} from "../../database/repositories/factory.js";
import {
decryptProtocolLogin,
type HostProtocolAuthRecord,
type HostProtocolLogin,
} from "../../database/repositories/host-protocol-auth-repository.js";
import { DataCrypto } from "../../utils/data-crypto.js";
import { findUsableCredential } from "../usable-credential.js";
import {
isSecretReference,
resolveSecretReference,
} from "../external-secrets.js";
import {
findHostProtocol,
listHostProtocols,
plainFieldKeys,
secretFieldKeys,
type DeclaredHostProtocol,
} from "./registry.js";
import type { ProtocolAuthSummary } from "./summary.js";
export type { ProtocolAuthSummary } from "./summary.js";
export const PROTOCOL_AUTH_TYPES = ["direct", "credential", "none"] as const;
export type ProtocolAuthType = (typeof PROTOCOL_AUTH_TYPES)[number];
/** One protocol's login as a client sends it. Absent keys keep what is stored. */
export interface ProtocolAuthInput {
authType?: unknown;
credentialId?: unknown;
username?: unknown;
password?: unknown;
fields?: Record<string, unknown>;
}
/** Protocol id to its new login, or null to remove it. */
export type ProtocolAuthPatch = Map<string, ProtocolAuthInput | null>;
/** A login ready to hand to the program that connects. */
export interface ResolvedProtocolLogin {
authType: string;
username: string;
password: string;
fields: Record<string, string>;
}
export class ProtocolAuthWriteError extends Error {
constructor(
message: string,
readonly status: number,
) {
super(message);
this.name = "ProtocolAuthWriteError";
}
}
/** The owner's data key; throws while it is locked. */
function ownerKey(ownerId: string): Buffer {
const dek = DataCrypto.getUserDataKey(ownerId);
if (!dek) throw new Error(`The data key of ${ownerId} is locked`);
return dek;
}
function isObject(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function str(value: unknown): string {
return typeof value === "string" ? value : "";
}
function capitalize(value: string): string {
return value.charAt(0).toUpperCase() + value.slice(1);
}
function asCredentialId(value: unknown): number | null {
const id = typeof value === "string" ? Number(value) : value;
return typeof id === "number" && Number.isInteger(id) && id > 0 ? id : null;
}
function asAuthType(value: unknown): ProtocolAuthType | undefined {
return PROTOCOL_AUTH_TYPES.includes(value as ProtocolAuthType)
? (value as ProtocolAuthType)
: undefined;
}
/**
* The flat keys a 2.8 client or export used for a protocol:
* `<id>User`, `<id>Password`, `<id>CredentialId`, `<id>AuthType` and
* `<id><Field>` for each declared field.
*/
function legacyInput(
hostData: Record<string, unknown>,
protocol: DeclaredHostProtocol,
): ProtocolAuthInput | null | undefined {
const prefix = protocol.id.replace(/-([a-z0-9])/g, (_, c: string) =>
c.toUpperCase(),
);
const key = (suffix: string) => `${prefix}${suffix}`;
const has = (name: string) =>
Object.prototype.hasOwnProperty.call(hostData, name);
const fieldKeys = (protocol.credentialFields ?? []).map((field) => field.key);
const names = [
key("User"),
key("Password"),
key("CredentialId"),
key("AuthType"),
...fieldKeys.map((field) => key(capitalize(field))),
];
if (!names.some(has)) return undefined;
const input: ProtocolAuthInput = {};
if (has(key("AuthType"))) input.authType = hostData[key("AuthType")];
if (has(key("CredentialId"))) {
input.credentialId = hostData[key("CredentialId")];
}
if (has(key("User"))) input.username = hostData[key("User")];
if (has(key("Password"))) {
// A 2.8 editor sent null for "leave the saved password alone".
const password = hostData[key("Password")];
if (password) input.password = password;
}
const fields: Record<string, unknown> = {};
for (const field of fieldKeys) {
const name = key(capitalize(field));
if (has(name)) fields[field] = hostData[name];
}
if (Object.keys(fields).length) input.fields = fields;
// A 2.8 editor cleared a protocol it switched off by sending nulls.
if (
has(key("AuthType")) &&
input.authType == null &&
!input.username &&
!input.password &&
!input.credentialId &&
Object.values(fields).every((value) => !value)
) {
return null;
}
return input;
}
/**
* The protocol logins a host write carries: `protocolAuth` keyed by
* protocol id, or the flat fields a 2.8 client sends. Null when it carries
* none, which leaves every stored login alone.
*/
export function readProtocolAuthPayload(
hostData: Record<string, unknown>,
): ProtocolAuthPatch | null {
const patch: ProtocolAuthPatch = new Map();
if (isObject(hostData.protocolAuth)) {
for (const [id, value] of Object.entries(hostData.protocolAuth)) {
if (!findHostProtocol(id)) continue;
if (value === null) patch.set(id, null);
else if (isObject(value)) patch.set(id, value as ProtocolAuthInput);
}
} else {
for (const protocol of listHostProtocols()) {
const input = legacyInput(hostData, protocol);
if (input !== undefined) patch.set(protocol.id, input);
}
}
return patch.size ? patch : null;
}
/** Folds a client's input over the stored login. Pure. */
export function mergeProtocolLogin(
protocolId: string,
declared: DeclaredHostProtocol | undefined,
current: HostProtocolLogin | null,
input: ProtocolAuthInput,
): HostProtocolLogin {
const inputCredential =
input.credentialId === undefined
? undefined
: asCredentialId(input.credentialId);
const authType: ProtocolAuthType =
asAuthType(input.authType) ??
(input.authType === undefined
? asAuthType(current?.authType)
: undefined) ??
(inputCredential ? "credential" : "direct");
const credentialId =
authType === "credential"
? inputCredential !== undefined
? inputCredential
: (current?.credentialId ?? null)
: null;
const direct = authType === "direct";
const username = direct
? input.username !== undefined
? str(input.username) || null
: (current?.username ?? null)
: null;
// An empty password keeps the saved one, as 2.8 did: the editor never
// shows it, and a shared editor cannot see whether one is set.
const password = direct
? str(input.password) || (current?.password ?? null)
: null;
const incoming = isObject(input.fields) ? input.fields : {};
const pick = (
keys: string[],
stored: Record<string, string>,
keepEmpty: boolean,
): Record<string, string> => {
const out: Record<string, string> = {};
for (const key of keys) {
const given = Object.prototype.hasOwnProperty.call(incoming, key)
? str(incoming[key])
: "";
const value =
given ||
(keepEmpty || !Object.prototype.hasOwnProperty.call(incoming, key)
? (stored[key] ?? "")
: "");
if (value) out[key] = value;
}
return out;
};
return {
protocol: protocolId,
authType,
credentialId,
username,
password,
fields: declared
? pick(plainFieldKeys(declared), current?.fields ?? {}, false)
: {},
secretFields: declared
? pick(secretFieldKeys(declared), current?.secretFields ?? {}, true)
: {},
};
}
function isEmptyLogin(login: HostProtocolLogin): boolean {
return (
login.authType === "direct" &&
!login.username &&
!login.password &&
Object.keys(login.fields).length === 0 &&
Object.keys(login.secretFields).length === 0
);
}
export type PlannedProtocolAuth = Array<[string, HostProtocolLogin | null]>;
/**
* Works out a host write's protocol logins without writing them. A shared
* editor works on the owner's record but may not point a login at a
* different credential, which throws a 403 before anything is written.
*/
export async function planProtocolAuthWrite(
ownerId: string,
hostId: number,
patch: ProtocolAuthPatch,
options: { isOwner: boolean },
): Promise<PlannedProtocolAuth> {
const repository = createCurrentHostProtocolAuthRepository();
const dek = ownerKey(ownerId);
const planned: PlannedProtocolAuth = [];
for (const [protocolId, input] of patch) {
const current = await repository.find(hostId, protocolId, dek);
const next =
input === null
? null
: mergeProtocolLogin(
protocolId,
findHostProtocol(protocolId),
current,
input,
);
if (
!options.isOwner &&
(next?.credentialId ?? null) !== (current?.credentialId ?? null)
) {
throw new ProtocolAuthWriteError(
`Only the host owner can change the ${protocolId} credential`,
403,
);
}
planned.push([protocolId, next && isEmptyLogin(next) ? null : next]);
}
return planned;
}
export async function applyProtocolAuthPlan(
ownerId: string,
hostId: number,
planned: PlannedProtocolAuth,
): Promise<void> {
const repository = createCurrentHostProtocolAuthRepository();
const dek = ownerKey(ownerId);
for (const [protocolId, next] of planned) {
if (next) await repository.upsert(ownerId, hostId, next, dek);
else await repository.delete(hostId, protocolId);
}
}
export async function writeProtocolAuth(
ownerId: string,
hostId: number,
patch: ProtocolAuthPatch,
options: { isOwner: boolean },
): Promise<void> {
await applyProtocolAuthPlan(
ownerId,
hostId,
await planProtocolAuthWrite(ownerId, hostId, patch, options),
);
}
/**
* An import names credentials by another server's ids. Any the importing
* user cannot use falls back to a direct login.
*/
export async function keepUsableProtocolCredentials(
patch: ProtocolAuthPatch,
userId: string,
): Promise<ProtocolAuthPatch> {
const out: ProtocolAuthPatch = new Map();
for (const [protocol, input] of patch) {
const credentialId = asCredentialId(input?.credentialId);
if (
input &&
credentialId &&
!(await findUsableCredential(credentialId, userId))
) {
out.set(protocol, {
...input,
credentialId: null,
authType: input.authType === "credential" ? "direct" : input.authType,
});
} else {
out.set(protocol, input);
}
}
return out;
}
/** The first username a write's logins carry, for a host with no SSH username. */
export function firstProtocolUsername(patch: ProtocolAuthPatch | null): string {
for (const input of patch?.values() ?? []) {
const username = str(input?.username);
if (username) return username;
}
return "";
}
/** Replaces every login of a host with the given ones, as a sync or import does. */
export async function replaceProtocolLogins(
ownerId: string,
hostId: number,
logins: HostProtocolLogin[],
): Promise<void> {
const repository = createCurrentHostProtocolAuthRepository();
const dek = ownerKey(ownerId);
const keep = new Set<string>();
for (const login of logins) {
keep.add(login.protocol);
if (isEmptyLogin(login)) await repository.delete(hostId, login.protocol);
else await repository.upsert(ownerId, hostId, login, dek);
}
for (const row of await repository.listRowsForHost(hostId)) {
if (!keep.has(row.protocol)) await repository.delete(hostId, row.protocol);
}
}
/** Every login of a host, decrypted with its owner's key. */
export async function listProtocolLogins(
hostId: number,
ownerId: string,
): Promise<HostProtocolLogin[]> {
const dek = ownerKey(ownerId);
return createCurrentHostProtocolAuthRepository().listForHost(hostId, dek);
}
function summarize(
row: HostProtocolAuthRecord,
dek: Buffer | null,
): ProtocolAuthSummary {
const login = dek ? decryptProtocolLogin(row, dek) : null;
let fields: Record<string, string> = {};
try {
const parsed = row.fields ? JSON.parse(row.fields) : {};
if (isObject(parsed)) {
fields = Object.fromEntries(
Object.entries(parsed).filter(
(entry): entry is [string, string] => typeof entry[1] === "string",
),
);
}
} catch {
fields = {};
}
return {
authType: row.authType || "direct",
credentialId: row.credentialId ?? null,
username: row.username ?? null,
fields,
hasPassword: !!row.password,
secretFieldKeys: login ? Object.keys(login.secretFields) : [],
};
}
/** Browser-safe logins for a list of hosts, keyed by host id then protocol. */
export async function loadProtocolAuthSummaries(
hosts: Array<Record<string, unknown>>,
): Promise<Map<number, Record<string, ProtocolAuthSummary>>> {
const ids = hosts
.map((host) => Number(host.id))
.filter((id) => Number.isInteger(id) && id > 0);
const result = new Map<number, Record<string, ProtocolAuthSummary>>();
if (ids.length === 0) return result;
const rows =
await createCurrentHostProtocolAuthRepository().listRowsForHosts(ids);
const keys = new Map<string, Buffer | null>();
for (const row of rows) {
if (!keys.has(row.userId)) {
keys.set(row.userId, DataCrypto.getUserDataKey(row.userId));
}
const own = result.get(row.hostId) ?? {};
own[row.protocol] = summarize(row, keys.get(row.userId) ?? null);
result.set(row.hostId, own);
}
return result;
}
/** Sets `protocolAuth` on each host from the loaded summaries. */
export function attachProtocolAuth(
hosts: Array<Record<string, unknown>>,
summaries: Map<number, Record<string, ProtocolAuthSummary>>,
): void {
for (const host of hosts) {
host.protocolAuth = summaries.get(Number(host.id)) ?? {};
}
}
export async function withProtocolAuth<T extends Record<string, unknown>>(
host: T,
): Promise<T> {
attachProtocolAuth([host], await loadProtocolAuthSummaries([host]));
return host;
}
async function openReference(userId: string, value: string): Promise<string> {
return isSecretReference(value)
? resolveSecretReference(userId, value)
: value;
}
function withDeclaredFields(
declared: DeclaredHostProtocol,
...sources: Array<Record<string, string> | undefined>
): Record<string, string> {
const out: Record<string, string> = {};
for (const field of declared.credentialFields ?? []) {
out[field.key] = "";
for (const source of sources) {
if (source?.[field.key]) {
out[field.key] = source[field.key];
break;
}
}
}
return out;
}
/** The owner's own login: the stored one, or the credential it points at. */
export async function resolveOwnerProtocolLogin(
host: Record<string, unknown>,
declared: DeclaredHostProtocol,
): Promise<ResolvedProtocolLogin> {
const hostId = Number(host.id);
const ownerId = String(host.userId);
const dek = ownerKey(ownerId);
const login = await createCurrentHostProtocolAuthRepository().find(
hostId,
declared.id,
dek,
);
const authType = login?.authType || "direct";
let username = login?.username ?? "";
let password = login?.password ?? "";
if (authType === "credential" && login?.credentialId) {
const credential = await findUsableCredential(login.credentialId, ownerId);
// Only the login itself comes from a stored credential, never the fields.
if (credential?.username) username = credential.username;
if (credential?.password) password = credential.password;
}
const fallback = declared.hostLoginFallback ?? [];
if (fallback.includes("username")) username ||= str(host.username);
if (fallback.includes("password")) password ||= str(host.password);
const secretFields: Record<string, string> = {};
for (const [key, value] of Object.entries(login?.secretFields ?? {})) {
secretFields[key] = await openReference(ownerId, value);
}
return {
authType,
username,
password: password ? await openReference(ownerId, password) : "",
fields: withDeclaredFields(declared, secretFields, login?.fields),
};
}
/**
* A shared recipient's login: their own override credential, the owner's
* shared snapshot, or nothing. Never the owner's stored secret.
*/
export async function resolveRecipientProtocolLogin(
host: Record<string, unknown>,
userId: string,
declared: DeclaredHostProtocol,
): Promise<ResolvedProtocolLogin> {
const hostId = Number(host.id);
const { resolveRecipientSharedHostAuthentication } =
await import("../../utils/shared-host-auth-resolver.js");
const row = await createCurrentHostProtocolAuthRepository().findRow(
hostId,
declared.id,
);
const ownerFields = row ? summarize(row, null).fields : {};
let resolution: Awaited<
ReturnType<typeof resolveRecipientSharedHostAuthentication>
>;
try {
resolution = await resolveRecipientSharedHostAuthentication(
{ ...host, password: null } as never,
hostId,
userId,
declared.id,
);
} catch {
resolution = { source: "required" };
}
if (resolution.source === "personal-override") {
return {
authType: "credential",
username: str(resolution.credential.username),
password: str(resolution.credential.password),
fields: withDeclaredFields(declared, ownerFields),
};
}
if (resolution.source === "owner-shared") {
return {
authType: resolution.authType,
username: str(resolution.secret?.username),
password: str(resolution.secret?.password),
fields: withDeclaredFields(
declared,
resolution.secret?.fields,
ownerFields,
),
};
}
return {
authType:
resolution.source === "secretless" && row?.authType === "none"
? "none"
: "direct",
username: "",
password: "",
fields: withDeclaredFields(declared, ownerFields),
};
}
/** Credential ids a recipient chose for this host, per protocol. */
export async function listRecipientOverrideIds(
hostId: number,
userId: string,
): Promise<Record<string, number>> {
return (await createCurrentSharedHostAuthOverrideRepository().listCredentialIds(
hostId,
userId,
)) as Record<string, number>;
}
/** A login in an export or on the sync wire: plaintext, keyed by protocol. */
export interface PortableProtocolLogin {
authType: string;
credentialId: number | null;
username: string | null;
password: string | null;
fields: Record<string, string>;
}
export function toPortableLogins(
logins: HostProtocolLogin[],
): Record<string, PortableProtocolLogin> {
const out: Record<string, PortableProtocolLogin> = {};
for (const login of logins) {
out[login.protocol] = {
authType: login.authType,
credentialId: login.credentialId,
username: login.username,
password: login.password,
fields: { ...login.fields, ...login.secretFields },
};
}
return out;
}
/**
* Reads a login back from an export or the wire. Fields are split by the
* protocol's declaration; an undeclared protocol keeps every field secret.
*/
export function fromPortableLogin(
protocolId: string,
value: unknown,
credentialId: number | null,
): HostProtocolLogin | null {
if (!isObject(value)) return null;
const declared = findHostProtocol(protocolId);
const secret = new Set(declared ? secretFieldKeys(declared) : []);
const plain = new Set(declared ? plainFieldKeys(declared) : []);
const fields: Record<string, string> = {};
const secretFields: Record<string, string> = {};
if (isObject(value.fields)) {
for (const [key, entry] of Object.entries(value.fields)) {
if (typeof entry !== "string" || !entry) continue;
if (plain.has(key)) fields[key] = entry;
else if (secret.has(key) || !declared) secretFields[key] = entry;
}
}
const authType = asAuthType(value.authType) ?? "direct";
return {
protocol: protocolId,
authType,
credentialId: authType === "credential" ? credentialId : null,
username: str(value.username) || null,
password: str(value.password) || null,
fields,
secretFields,
};
}
@@ -0,0 +1,63 @@
/**
* The host protocols plugins declare in contributes.protocols, read from
* every installed plugin's manifest (enabled or not), so core can store,
* share and sync a protocol's login without the plugin's code running.
*/
import type { PluginProtocolContribution } from "@termix/plugin-sdk/manifest";
import { SSH_AUTH_PROTOCOL } from "../../../types/auth-protocols.js";
export interface DeclaredHostProtocol extends PluginProtocolContribution {
pluginId: string;
pluginName: string;
}
let source: () => DeclaredHostProtocol[] = () => [];
export function setHostProtocolSource(
next: () => DeclaredHostProtocol[],
): void {
source = next;
}
/** One entry per protocol id; the first plugin to declare an id keeps it. */
export function listHostProtocols(): DeclaredHostProtocol[] {
const seen = new Set<string>();
const out: DeclaredHostProtocol[] = [];
for (const protocol of source()) {
if (seen.has(protocol.id)) continue;
seen.add(protocol.id);
out.push(protocol);
}
return out;
}
export function findHostProtocol(
id: unknown,
): DeclaredHostProtocol | undefined {
if (typeof id !== "string") return undefined;
return listHostProtocols().find((protocol) => protocol.id === id);
}
export function isHostProtocol(id: unknown): id is string {
return !!findHostProtocol(id);
}
export function secretFieldKeys(
protocol: PluginProtocolContribution,
): string[] {
return (protocol.credentialFields ?? [])
.filter((field) => field.secret === true)
.map((field) => field.key);
}
export function plainFieldKeys(protocol: PluginProtocolContribution): string[] {
return (protocol.credentialFields ?? [])
.filter((field) => field.secret !== true)
.map((field) => field.key);
}
/** "ssh" or a declared protocol: what a shared recipient may override. */
export function isAuthOverrideProtocol(value: unknown): value is string {
return value === SSH_AUTH_PROTOCOL || isHostProtocol(value);
}
@@ -0,0 +1,43 @@
/**
* The browser-facing shape of a host's protocol logins. No database access,
* so the host normalizers can use it.
*/
/** What a browser sees of a login: no secret values, only whether they are set. */
export interface ProtocolAuthSummary {
authType: string;
credentialId: number | null;
username: string | null;
fields: Record<string, string>;
hasPassword: boolean;
secretFieldKeys: string[];
}
function isObject(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
/**
* What a shared recipient may see of each login: never whether secrets are
* set, and at connect level only the auth type.
*/
export function sanitizeProtocolAuthForRecipient(
value: unknown,
permissionLevel: string | undefined,
): Record<string, unknown> {
if (!isObject(value)) return {};
const out: Record<string, unknown> = {};
for (const [protocol, summary] of Object.entries(value)) {
if (!isObject(summary)) continue;
out[protocol] =
permissionLevel === "connect"
? { authType: summary.authType }
: {
authType: summary.authType,
credentialId: summary.credentialId ?? null,
username: summary.username ?? null,
fields: summary.fields ?? {},
};
}
return out;
}