feat: move termix identity into a plugin (C6)

This commit is contained in:
LukeGus committed 2026-09-24 20:50:54 -05:00
1 parent 6e56c90aac
commit 4dbce08379
157 files changed
+20743 -5766

No files matched your search

+7 -3
View File
@@ -7,7 +7,10 @@ import multer from "multer";
import cookieParser from "cookie-parser";
import userRoutes from "./routes/users.js";
import hostRoutes from "./routes/host.js";
import { registerVaultCompatRoutes } from "./routes/host-compat-routes.js";
import {
registerTermixIdCompatRoutes,
registerVaultCompatRoutes,
} from "./routes/host-compat-routes.js";
import alertRoutes from "./routes/alerts.js";
import credentialsRoutes from "./routes/credentials.js";
import sshAuthRoutes from "./routes/ssh-auth-routes.js";
@@ -17,7 +20,6 @@ import userPreferencesRoutes from "./routes/user-preferences.js";
import hostSidebarPreferencesRoutes from "./routes/host-sidebar-preferences.js";
import credentialSidebarPreferencesRoutes from "./routes/credential-sidebar-preferences.js";
import uiPreferencesRoutes from "./routes/ui-preferences.js";
import termixIdRoutes from "./routes/termix-id.js";
import { registerAuditLogRoutes } from "./routes/audit-log-routes.js";
import notificationChannelsRoutes from "./routes/notification-channels-routes.js";
import syncRoutes from "./routes/sync.js";
@@ -1793,7 +1795,9 @@ app.use("/user-preferences", userPreferencesRoutes);
app.use("/host-sidebar/preferences", hostSidebarPreferencesRoutes);
app.use("/credential-sidebar/preferences", credentialSidebarPreferencesRoutes);
app.use("/ui-preferences", uiPreferencesRoutes);
app.use("/termix-id", termixIdRoutes);
const termixIdCompatRoutes = express.Router();
registerTermixIdCompatRoutes(termixIdCompatRoutes);
app.use("/termix-id", termixIdCompatRoutes);
registerAuditLogRoutes(app, authenticateJWT);
const vaultCompatRoutes = express.Router();
registerVaultCompatRoutes(vaultCompatRoutes);
-105
View File
@@ -1204,111 +1204,6 @@ const migrateSchema = () => {
addColumnIfNotExists("file_manager_pinned", "host_id", "INTEGER NOT NULL");
addColumnIfNotExists("file_manager_shortcuts", "host_id", "INTEGER NOT NULL");
try {
sqlite.prepare("SELECT id FROM termix_identities LIMIT 1").get();
} catch {
try {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS termix_identities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL UNIQUE,
handle TEXT NOT NULL UNIQUE,
description TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
`);
} catch (createError) {
databaseLogger.warn("Failed to create termix_identities table", {
operation: "schema_migration",
error: createError,
});
}
}
// Enforce one-Termix-ID-per-user on databases where the table predates the
// UNIQUE(user_id) constraint above.
try {
sqlite.exec(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_termix_identities_user ON termix_identities(user_id)",
);
} catch (indexError) {
databaseLogger.warn("Failed to create termix_identities user_id unique index", {
operation: "schema_migration",
error: indexError,
});
}
try {
sqlite.prepare("SELECT id FROM termix_identity_keys LIMIT 1").get();
} catch {
try {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS termix_identity_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
identity_id INTEGER NOT NULL,
user_id TEXT NOT NULL,
public_key TEXT NOT NULL,
key_type TEXT NOT NULL,
algorithm TEXT NOT NULL,
label TEXT,
comment TEXT,
source TEXT NOT NULL DEFAULT 'manual',
credential_id INTEGER,
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (identity_id) REFERENCES termix_identities (id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (credential_id) REFERENCES ssh_credentials (id) ON DELETE SET NULL
);
`);
} catch (createError) {
databaseLogger.warn("Failed to create termix_identity_keys table", {
operation: "schema_migration",
error: createError,
});
}
}
// The public resolver fetches keys by identity_id on every request; index it.
try {
sqlite.exec(
"CREATE INDEX IF NOT EXISTS idx_termix_identity_keys_identity ON termix_identity_keys(identity_id)",
);
} catch (indexError) {
databaseLogger.warn("Failed to create termix_identity_keys identity index", {
operation: "schema_migration",
error: indexError,
});
}
try {
sqlite.prepare("SELECT id FROM termix_identity_ca LIMIT 1").get();
} catch {
try {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS termix_identity_ca (
id INTEGER PRIMARY KEY AUTOINCREMENT,
identity_id INTEGER NOT NULL UNIQUE,
user_id TEXT NOT NULL,
public_key TEXT NOT NULL,
private_key TEXT NOT NULL,
validity_days INTEGER NOT NULL DEFAULT 90,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (identity_id) REFERENCES termix_identities (id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
`);
} catch (createError) {
databaseLogger.warn("Failed to create termix_identity_ca table", {
operation: "schema_migration",
error: createError,
});
}
}
try {
sqlite.prepare("SELECT role_id FROM host_access LIMIT 1").get();
} catch {
+2 -72
View File
@@ -772,78 +772,8 @@ export const uiPreferences = mysqlTable("ui_preferences", {
// dashboard_service_links moved to the homepage plugin (p_homepage_dashboard_service_links).
// --- termix-id begin ---
// A user claims a unique public handle. Their published SSH public keys are
// served at an unauthenticated resolver endpoint in authorized_keys format,
// so any server can be provisioned with `curl <host>/termix-id/u/<handle> >> ~/.ssh/authorized_keys`.
export const termixIdentities = mysqlTable("termix_identities", {
id: int("id").autoincrement().primaryKey(),
// One Termix ID per user — enforced in schema, not just in code.
userId: varchar("user_id", { length: 255 })
.notNull()
.unique()
.references(() => users.id, { onDelete: "cascade" }),
handle: varchar("handle", { length: 255 }).notNull().unique(),
description: text("description"),
createdAt: text("created_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
updatedAt: text("updated_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
});
export const termixIdentityKeys = mysqlTable("termix_identity_keys", {
id: int("id").autoincrement().primaryKey(),
identityId: int("identity_id")
.notNull()
.references(() => termixIdentities.id, { onDelete: "cascade" }),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
// Public keys are non-secret, so they are stored in plaintext (no field-level
// encryption). This is what lets the unauthenticated resolver serve them.
publicKey: text("public_key").notNull(),
// Raw algorithm token (e.g. "ssh-ed25519"), and a normalized group used for
// the /<ALGO> resolver filter (RSA / ED25519 / ECDSA / ...).
keyType: text("key_type").notNull(),
algorithm: text("algorithm").notNull(),
label: text("label"),
comment: text("comment"),
// "manual" (pasted) or "credential" (imported from an ssh_credentials entry).
source: text("source").notNull().default("manual"),
credentialId: int("credential_id").references(() => sshCredentials.id, {
onDelete: "set null",
}),
enabled: boolean("enabled").notNull().default(true),
createdAt: text("created_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
});
// Per-identity certificate authority. Servers that trust this CA (via
// TrustedUserCAKeys / @cert-authority) accept any user certificate it signs,
// giving central revocation (rotate the CA) and expiry (cert validity).
export const termixIdentityCa = mysqlTable("termix_identity_ca", {
id: int("id").autoincrement().primaryKey(),
identityId: int("identity_id")
.notNull()
.unique()
.references(() => termixIdentities.id, { onDelete: "cascade" }),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
// CA public key (plaintext — it is published); CA private key is field-encrypted.
publicKey: text("public_key").notNull(),
privateKey: text("private_key").notNull(),
validityDays: int("validity_days").notNull().default(90),
createdAt: text("created_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
updatedAt: text("updated_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
});
// --- termix-id end ---
// termix_identities, termix_identity_keys and termix_identity_ca moved to the
// termix-identity plugin (p_termix_identity_*).
// --- alerts begin ---
export const notificationChannels = mysqlTable("notification_channels", {
+2 -72
View File
@@ -773,78 +773,8 @@ export const uiPreferences = pgTable("ui_preferences", {
// dashboard_service_links moved to the homepage plugin (p_homepage_dashboard_service_links).
// --- termix-id begin ---
// A user claims a unique public handle. Their published SSH public keys are
// served at an unauthenticated resolver endpoint in authorized_keys format,
// so any server can be provisioned with `curl <host>/termix-id/u/<handle> >> ~/.ssh/authorized_keys`.
export const termixIdentities = pgTable("termix_identities", {
id: serial("id").primaryKey(),
// One Termix ID per user — enforced in schema, not just in code.
userId: varchar("user_id", { length: 255 })
.notNull()
.unique()
.references(() => users.id, { onDelete: "cascade" }),
handle: varchar("handle", { length: 255 }).notNull().unique(),
description: text("description"),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
export const termixIdentityKeys = pgTable("termix_identity_keys", {
id: serial("id").primaryKey(),
identityId: integer("identity_id")
.notNull()
.references(() => termixIdentities.id, { onDelete: "cascade" }),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
// Public keys are non-secret, so they are stored in plaintext (no field-level
// encryption). This is what lets the unauthenticated resolver serve them.
publicKey: text("public_key").notNull(),
// Raw algorithm token (e.g. "ssh-ed25519"), and a normalized group used for
// the /<ALGO> resolver filter (RSA / ED25519 / ECDSA / ...).
keyType: text("key_type").notNull(),
algorithm: text("algorithm").notNull(),
label: text("label"),
comment: text("comment"),
// "manual" (pasted) or "credential" (imported from an ssh_credentials entry).
source: text("source").notNull().default("manual"),
credentialId: integer("credential_id").references(() => sshCredentials.id, {
onDelete: "set null",
}),
enabled: boolean("enabled").notNull().default(true),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
// Per-identity certificate authority. Servers that trust this CA (via
// TrustedUserCAKeys / @cert-authority) accept any user certificate it signs,
// giving central revocation (rotate the CA) and expiry (cert validity).
export const termixIdentityCa = pgTable("termix_identity_ca", {
id: serial("id").primaryKey(),
identityId: integer("identity_id")
.notNull()
.unique()
.references(() => termixIdentities.id, { onDelete: "cascade" }),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
// CA public key (plaintext — it is published); CA private key is field-encrypted.
publicKey: text("public_key").notNull(),
privateKey: text("private_key").notNull(),
validityDays: integer("validity_days").notNull().default(90),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
// --- termix-id end ---
// termix_identities, termix_identity_keys and termix_identity_ca moved to the
// termix-identity plugin (p_termix_identity_*).
// --- alerts begin ---
export const notificationChannels = pgTable("notification_channels", {
+2 -72
View File
@@ -769,78 +769,8 @@ export const uiPreferences = sqliteTable("ui_preferences", {
// dashboard_service_links moved to the homepage plugin (p_homepage_dashboard_service_links).
// --- termix-id begin ---
// A user claims a unique public handle. Their published SSH public keys are
// served at an unauthenticated resolver endpoint in authorized_keys format,
// so any server can be provisioned with `curl <host>/termix-id/u/<handle> >> ~/.ssh/authorized_keys`.
export const termixIdentities = sqliteTable("termix_identities", {
id: integer("id").primaryKey({ autoIncrement: true }),
// One Termix ID per user — enforced in schema, not just in code.
userId: text("user_id")
.notNull()
.unique()
.references(() => users.id, { onDelete: "cascade" }),
handle: text("handle").notNull().unique(),
description: text("description"),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
export const termixIdentityKeys = sqliteTable("termix_identity_keys", {
id: integer("id").primaryKey({ autoIncrement: true }),
identityId: integer("identity_id")
.notNull()
.references(() => termixIdentities.id, { onDelete: "cascade" }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
// Public keys are non-secret, so they are stored in plaintext (no field-level
// encryption). This is what lets the unauthenticated resolver serve them.
publicKey: text("public_key", { length: 8192 }).notNull(),
// Raw algorithm token (e.g. "ssh-ed25519"), and a normalized group used for
// the /<ALGO> resolver filter (RSA / ED25519 / ECDSA / ...).
keyType: text("key_type").notNull(),
algorithm: text("algorithm").notNull(),
label: text("label"),
comment: text("comment"),
// "manual" (pasted) or "credential" (imported from an ssh_credentials entry).
source: text("source").notNull().default("manual"),
credentialId: integer("credential_id").references(() => sshCredentials.id, {
onDelete: "set null",
}),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
// Per-identity certificate authority. Servers that trust this CA (via
// TrustedUserCAKeys / @cert-authority) accept any user certificate it signs,
// giving central revocation (rotate the CA) and expiry (cert validity).
export const termixIdentityCa = sqliteTable("termix_identity_ca", {
id: integer("id").primaryKey({ autoIncrement: true }),
identityId: integer("identity_id")
.notNull()
.unique()
.references(() => termixIdentities.id, { onDelete: "cascade" }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
// CA public key (plaintext — it is published); CA private key is field-encrypted.
publicKey: text("public_key", { length: 4096 }).notNull(),
privateKey: text("private_key", { length: 8192 }).notNull(),
validityDays: integer("validity_days").notNull().default(90),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
// --- termix-id end ---
// termix_identities, termix_identity_keys and termix_identity_ca moved to the
// termix-identity plugin (p_termix_identity_*).
// --- alerts begin ---
export const notificationChannels = sqliteTable("notification_channels", {
@@ -36,8 +36,6 @@ import { SharedHostAuthOverrideRepository } from "./shared-host-auth-override-re
import { SharedHostSecretsRepository } from "./shared-host-secrets-repository.js";
import { SshCredentialUsageRepository } from "./ssh-credential-usage-repository.js";
import { SyncTombstoneRepository } from "./sync-tombstone-repository.js";
import { TermixIdentityCaRepository } from "./termix-identity-ca-repository.js";
import { TermixIdentityRepository } from "./termix-identity-repository.js";
import { TrustedDeviceRepository } from "./trusted-device-repository.js";
import { UserDataExportRepository } from "./user-data-export-repository.js";
import { UserPreferenceRepository } from "./user-preference-repository.js";
@@ -349,20 +347,6 @@ export function createCurrentSshCredentialUsageRepository(): SshCredentialUsageR
);
}
export function createCurrentTermixIdentityCaRepository(): TermixIdentityCaRepository {
return new TermixIdentityCaRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("termix_identity_ca_repository_write"),
);
}
export function createCurrentTermixIdentityRepository(): TermixIdentityRepository {
return new TermixIdentityRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("termix_identity_repository_write"),
);
}
// transfer_recent moved to the file-manager plugin.
// tmux_session_tags moved to the tmux-monitor plugin.
@@ -1,223 +0,0 @@
import { eq } from "drizzle-orm";
import { termixIdentityCa } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { DataCrypto } from "../../utils/data-crypto.js";
import {
insertedId,
rowsAffected,
supportsReturning,
} from "./mutation-result.js";
import { updateReturning } from "./returning.js";
export type TermixIdentityCaRecord = typeof termixIdentityCa.$inferSelect;
export type NewTermixIdentityCaRecord = typeof termixIdentityCa.$inferInsert;
export type TermixIdentityCaUpdate = Partial<
Pick<
NewTermixIdentityCaRecord,
"publicKey" | "privateKey" | "validityDays" | "updatedAt"
>
>;
export class TermixIdentityCaRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async findPublicByIdentityId(
identityId: number,
): Promise<Pick<
TermixIdentityCaRecord,
"publicKey" | "validityDays"
> | null> {
const rows = await this.context.drizzle
.select({
publicKey: termixIdentityCa.publicKey,
validityDays: termixIdentityCa.validityDays,
})
.from(termixIdentityCa)
.where(eq(termixIdentityCa.identityId, identityId))
.limit(1);
return rows[0] ?? null;
}
async findDecryptedByIdentityId(
userId: string,
identityId: number,
): Promise<TermixIdentityCaRecord | null> {
const rows = await this.context.drizzle
.select()
.from(termixIdentityCa)
.where(eq(termixIdentityCa.identityId, identityId))
.limit(1);
return this.decryptOne(rows[0] ?? null, userId);
}
async createEncryptedForUser(
userId: string,
ca: NewTermixIdentityCaRecord,
): Promise<TermixIdentityCaRecord> {
const userDataKey = DataCrypto.validateUserAccess(userId);
const result = await this.insertThenEncrypt(userId, ca, userDataKey);
await this.afterWrite();
return DataCrypto.decryptRecord(
"termix_identity_ca",
result,
userId,
userDataKey,
);
}
/**
* Writes a CA in two steps, because the ciphertext depends on the id.
*
* The private key is encrypted with the row's own id as context, which does
* not exist until the row does. So: insert with an empty key, encrypt, update.
* The empty key must never be observable, hence the transaction.
*
* Two branches because better-sqlite3 rejects an async transaction callback —
* see the same note in UserRepository.
*/
private async insertThenEncrypt(
userId: string,
ca: NewTermixIdentityCaRecord,
userDataKey: Buffer,
): Promise<TermixIdentityCaRecord> {
const draft = { ...ca, privateKey: "" };
const seal = (id: number) =>
DataCrypto.encryptRecord(
"termix_identity_ca",
{ id, privateKey: ca.privateKey },
userId,
userDataKey,
).privateKey;
if (this.context.dialect === "sqlite") {
/* eslint-disable no-restricted-syntax -- sqlite-only branch: the dialect
is checked directly above, and better-sqlite3 needs the synchronous
.all() form, which has no async equivalent. */
return this.context.drizzle.transaction((tx) => {
const row = tx
.insert(termixIdentityCa)
.values(draft)
.returning()
.all()[0];
return tx
.update(termixIdentityCa)
.set({ privateKey: seal(row.id) })
.where(eq(termixIdentityCa.id, row.id))
.returning()
.all()[0];
});
/* eslint-enable no-restricted-syntax */
}
return this.context.drizzle.transaction(async (tx) => {
let id: number | null;
if (supportsReturning(this.context.dialect)) {
// eslint-disable-next-line no-restricted-syntax -- guarded by the check above
const rows = await tx
.insert(termixIdentityCa)
.values(draft)
.returning();
id = rows[0]?.id ?? null;
} else {
id = insertedId(await tx.insert(termixIdentityCa).values(draft));
}
if (id === null) {
throw new Error("Insert into termix_identity_ca returned no id.");
}
await tx
.update(termixIdentityCa)
.set({ privateKey: seal(id) })
.where(eq(termixIdentityCa.id, id));
const [row] = await tx
.select()
.from(termixIdentityCa)
.where(eq(termixIdentityCa.id, id));
return row;
});
}
async updateEncryptedForIdentity(
userId: string,
identityId: number,
update: TermixIdentityCaUpdate,
): Promise<TermixIdentityCaRecord | null> {
const existing = await this.findDecryptedByIdentityId(userId, identityId);
if (!existing) return null;
const userDataKey = DataCrypto.validateUserAccess(userId);
const encryptedPrivateKey = update.privateKey
? DataCrypto.encryptRecord(
"termix_identity_ca",
{ id: existing.id, privateKey: update.privateKey },
userId,
userDataKey,
).privateKey
: undefined;
const rows = await updateReturning(
this.context,
termixIdentityCa,
{
...update,
...(encryptedPrivateKey ? { privateKey: encryptedPrivateKey } : {}),
},
eq(termixIdentityCa.identityId, identityId),
);
await this.afterWrite();
return this.decryptOne(rows[0] ?? null, userId);
}
async deleteByIdentityId(identityId: number): Promise<boolean> {
const result = await this.context.drizzle
.delete(termixIdentityCa)
.where(eq(termixIdentityCa.identityId, identityId));
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result) > 0;
}
async deleteByUserId(userId: string): Promise<number> {
const result = await this.context.drizzle
.delete(termixIdentityCa)
.where(eq(termixIdentityCa.userId, userId));
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result);
}
private decryptOne<T extends Record<string, unknown>>(
record: T | null,
userId: string,
): T | null {
if (!record) return null;
const userDataKey = DataCrypto.getUserDataKey(userId);
if (!userDataKey) return null;
return DataCrypto.decryptRecord(
"termix_identity_ca",
record,
userId,
userDataKey,
);
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
@@ -1,237 +0,0 @@
import { and, asc, eq } from "drizzle-orm";
import { termixIdentities, termixIdentityKeys } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import { insertReturning, updateReturning } from "./returning.js";
export type TermixIdentityRecord = typeof termixIdentities.$inferSelect;
export type NewTermixIdentityRecord = typeof termixIdentities.$inferInsert;
export type TermixIdentityUpdate = Partial<
Pick<NewTermixIdentityRecord, "handle" | "description" | "updatedAt">
>;
export type TermixIdentityKeyRecord = typeof termixIdentityKeys.$inferSelect;
export type NewTermixIdentityKeyRecord = typeof termixIdentityKeys.$inferInsert;
export type TermixIdentityKeyUpdate = Partial<
Pick<NewTermixIdentityKeyRecord, "enabled" | "label">
>;
export class TermixIdentityRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async findIdentityForUser(
userId: string,
): Promise<TermixIdentityRecord | null> {
const rows = await this.context.drizzle
.select()
.from(termixIdentities)
.where(eq(termixIdentities.userId, userId))
.limit(1);
return rows[0] ?? null;
}
async findIdentityByHandle(
handle: string,
): Promise<TermixIdentityRecord | null> {
const rows = await this.context.drizzle
.select()
.from(termixIdentities)
.where(eq(termixIdentities.handle, handle))
.limit(1);
return rows[0] ?? null;
}
async isHandleTaken(handle: string): Promise<boolean> {
const rows = await this.context.drizzle
.select({ id: termixIdentities.id })
.from(termixIdentities)
.where(eq(termixIdentities.handle, handle))
.limit(1);
return rows.length > 0;
}
async createIdentity(
identity: NewTermixIdentityRecord,
): Promise<TermixIdentityRecord> {
const rows = await insertReturning(
this.context,
termixIdentities,
identity,
);
await this.afterWrite();
return rows[0];
}
async updateIdentityForUser(
userId: string,
update: TermixIdentityUpdate,
): Promise<TermixIdentityRecord | null> {
const rows = await updateReturning(
this.context,
termixIdentities,
update,
eq(termixIdentities.userId, userId),
);
if (rows.length > 0) {
await this.afterWrite();
}
return rows[0] ?? null;
}
async deleteIdentityForUser(userId: string): Promise<boolean> {
const result = await this.context.drizzle
.delete(termixIdentities)
.where(eq(termixIdentities.userId, userId));
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result) > 0;
}
async deleteByUserId(userId: string): Promise<{
identitiesDeleted: number;
keysDeleted: number;
}> {
const keyResult = await this.context.drizzle
.delete(termixIdentityKeys)
.where(eq(termixIdentityKeys.userId, userId));
const result = await this.context.drizzle
.delete(termixIdentities)
.where(eq(termixIdentities.userId, userId));
if (rowsAffected(keyResult) > 0 || rowsAffected(result) > 0) {
await this.afterWrite();
}
return {
identitiesDeleted: rowsAffected(result),
keysDeleted: rowsAffected(keyResult),
};
}
async listKeysByIdentityId(
identityId: number,
): Promise<TermixIdentityKeyRecord[]> {
return this.context.drizzle
.select()
.from(termixIdentityKeys)
.where(eq(termixIdentityKeys.identityId, identityId))
.orderBy(asc(termixIdentityKeys.id));
}
async listEnabledKeysByIdentityId(
identityId: number,
): Promise<TermixIdentityKeyRecord[]> {
return this.context.drizzle
.select()
.from(termixIdentityKeys)
.where(
and(
eq(termixIdentityKeys.identityId, identityId),
eq(termixIdentityKeys.enabled, true),
),
)
.orderBy(asc(termixIdentityKeys.id));
}
async listLinkedCredentialIds(identityId: number): Promise<number[]> {
const rows = await this.context.drizzle
.select({ credentialId: termixIdentityKeys.credentialId })
.from(termixIdentityKeys)
.where(
and(
eq(termixIdentityKeys.identityId, identityId),
eq(termixIdentityKeys.enabled, true),
),
);
return Array.from(
new Set(
rows
.map((row) => row.credentialId)
.filter(
(credentialId): credentialId is number => credentialId !== null,
),
),
);
}
async createKey(
key: NewTermixIdentityKeyRecord,
): Promise<TermixIdentityKeyRecord> {
const rows = await insertReturning(this.context, termixIdentityKeys, key);
await this.afterWrite();
return rows[0];
}
async updateKeyForUser(
userId: string,
id: number,
update: TermixIdentityKeyUpdate,
): Promise<TermixIdentityKeyRecord | null> {
const rows = await updateReturning(
this.context,
termixIdentityKeys,
update,
and(eq(termixIdentityKeys.id, id), eq(termixIdentityKeys.userId, userId)),
);
if (rows.length > 0) {
await this.afterWrite();
}
return rows[0] ?? null;
}
async deleteKeyForUser(userId: string, id: number): Promise<boolean> {
const result = await this.context.drizzle
.delete(termixIdentityKeys)
.where(
and(
eq(termixIdentityKeys.id, id),
eq(termixIdentityKeys.userId, userId),
),
);
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result) > 0;
}
async findKeyForUser(
userId: string,
id: number,
): Promise<TermixIdentityKeyRecord | null> {
const rows = await this.context.drizzle
.select()
.from(termixIdentityKeys)
.where(
and(
eq(termixIdentityKeys.id, id),
eq(termixIdentityKeys.userId, userId),
),
)
.limit(1);
return rows[0] ?? null;
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
@@ -18,8 +18,6 @@ import {
createCurrentSettingsRepository,
createCurrentSharedHostSecretsRepository,
createCurrentSshCredentialUsageRepository,
createCurrentTermixIdentityCaRepository,
createCurrentTermixIdentityRepository,
createCurrentTrustedDeviceRepository,
createCurrentUserPreferenceRepository,
createCurrentUserRepository,
@@ -95,10 +93,8 @@ export async function deleteUserAndRelatedData(
// homepage and secret-sources plugins' adopted tables. The secret
// source's token in ctx.secrets is cleaned up generically below, with
// every other plugin_settings row for this user. The opkssh and vault
// plugins' tables cascade the same way.
// plugins' tables cascade the same way, and so do termix-identity's.
await createCurrentTermixIdentityCaRepository().deleteByUserId(userId);
await createCurrentTermixIdentityRepository().deleteByUserId(userId);
await createCurrentOpenTabRepository().deleteByUserId(userId);
await createCurrentUserPreferenceRepository().deleteByUserId(userId);
@@ -69,3 +69,33 @@ export function registerVaultCompatRoutes(router: Router): void {
res.redirect(307, localUrl(req, "/plugin-api/vault/oidc/callback"));
});
}
/**
* The 2.8 Termix ID resolver URLs, which servers fetch from provisioning
* scripts. Permanently redirected to the termix-identity plugin's public
* resolver; curl -L follows them.
*/
export function registerTermixIdCompatRoutes(router: Router): void {
/**
* @openapi
* /termix-id/u/{handle}:
* get:
* summary: Termix ID resolver (2.8 URL)
* description: Permanently redirects to /plugin-api/termix-identity/u/{handle}, and likewise for /termix-id/u/{handle}/{algo} and /termix-id/u/{handle}/ca, so servers provisioned with the old URL keep working.
* tags:
* - Termix ID
* parameters:
* - in: path
* name: handle
* required: true
* schema:
* type: string
* responses:
* 308:
* description: Redirect to the termix-identity plugin's resolver.
*/
router.get(["/u/:handle", "/u/:handle/:algo"], (req, res) => {
const rest = req.path.slice("/u".length);
res.redirect(308, localUrl(req, `/plugin-api/termix-identity/u${rest}`));
});
}
@@ -1,145 +0,0 @@
import crypto from "crypto";
// Minimal OpenSSH ed25519 user-certificate signer + per-user CA generation.
// Implemented in pure Node so we never shell out or move private keys to disk.
// Format reference: PROTOCOL.certkeys (ssh-ed25519-cert-v01@openssh.com).
const ED25519 = "ssh-ed25519";
const CERT_TYPE = "ssh-ed25519-cert-v01@openssh.com";
const SSH2_CERT_TYPE_USER = 1;
// Standard login extensions OpenSSH grants by default; must be name-sorted.
const DEFAULT_EXTENSIONS = [
"permit-X11-forwarding",
"permit-agent-forwarding",
"permit-port-forwarding",
"permit-pty",
"permit-user-rc",
];
// --- SSH wire-format primitives -------------------------------------------
function sshString(value: Buffer | string): Buffer {
const buf = typeof value === "string" ? Buffer.from(value, "utf8") : value;
const len = Buffer.alloc(4);
len.writeUInt32BE(buf.length, 0);
return Buffer.concat([len, buf]);
}
function sshUint32(n: number): Buffer {
const b = Buffer.alloc(4);
b.writeUInt32BE(n >>> 0, 0);
return b;
}
function sshUint64(n: bigint): Buffer {
const b = Buffer.alloc(8);
b.writeBigUInt64BE(n, 0);
return b;
}
function ed25519PublicBlob(raw32: Buffer): Buffer {
return Buffer.concat([sshString(ED25519), sshString(raw32)]);
}
/** Extract the 32-byte raw ed25519 key from an `ssh-ed25519 <base64>` line. */
export function ed25519RawFromLine(line: string): Buffer | null {
const parts = line.trim().split(/\s+/);
if (parts[0] !== ED25519 || !parts[1]) return null;
let blob: Buffer;
try {
blob = Buffer.from(parts[1], "base64");
} catch {
return null;
}
try {
let off = 0;
const typeLen = blob.readUInt32BE(off);
off += 4;
if (blob.toString("utf8", off, off + typeLen) !== ED25519) return null;
off += typeLen;
const keyLen = blob.readUInt32BE(off);
off += 4;
const pk = blob.subarray(off, off + keyLen);
return pk.length === 32 ? pk : null;
} catch {
return null;
}
}
// --- CA generation ---------------------------------------------------------
export interface GeneratedCa {
publicKeyLine: string; // "ssh-ed25519 <base64>"
privateKeyPem: string; // PKCS#8 PEM (to be stored encrypted)
}
export function generateCa(): GeneratedCa {
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
const jwk = publicKey.export({ format: "jwk" }) as { x: string };
const raw = Buffer.from(jwk.x, "base64url");
const publicKeyLine = `${ED25519} ${ed25519PublicBlob(raw).toString("base64")}`;
const privateKeyPem = privateKey
.export({ format: "pem", type: "pkcs8" })
.toString();
return { publicKeyLine, privateKeyPem };
}
// --- Certificate signing ---------------------------------------------------
export interface SignCertOptions {
userPublicKeyLine: string; // ed25519 public key to certify
caPrivateKeyPem: string;
caPublicKeyLine: string;
keyId: string;
principals: string[]; // empty = valid for all usernames
validAfter: number; // unix seconds
validBefore: number; // unix seconds
serial?: bigint;
}
/**
* Sign an ed25519 user public key into an OpenSSH user certificate.
* Returns the certificate line, or null if the inputs aren't ed25519.
*/
export function signUserCertificate(opts: SignCertOptions): string | null {
const pk = ed25519RawFromLine(opts.userPublicKeyLine);
const caRaw = ed25519RawFromLine(opts.caPublicKeyLine);
if (!pk || !caRaw) return null;
const signatureKey = ed25519PublicBlob(caRaw);
const principals = Buffer.concat(opts.principals.map((p) => sshString(p)));
const extensions = Buffer.concat(
[...DEFAULT_EXTENSIONS]
.sort()
.map((name) => Buffer.concat([sshString(name), sshString("")])),
);
// Everything up to (not including) the signature — this is what gets signed.
const body = Buffer.concat([
sshString(CERT_TYPE),
sshString(crypto.randomBytes(32)), // nonce
sshString(pk),
sshUint64(opts.serial ?? 0n),
sshUint32(SSH2_CERT_TYPE_USER),
sshString(opts.keyId),
sshString(principals),
sshUint64(BigInt(opts.validAfter)),
sshUint64(BigInt(opts.validBefore)),
sshString(Buffer.alloc(0)), // critical options (none)
sshString(extensions),
sshString(Buffer.alloc(0)), // reserved
sshString(signatureKey),
]);
const caKey = crypto.createPrivateKey({
key: opts.caPrivateKeyPem,
format: "pem",
type: "pkcs8",
});
const rawSig = crypto.sign(null, body, caKey); // ed25519 -> 64 bytes
const signature = Buffer.concat([sshString(ED25519), sshString(rawSig)]);
const cert = Buffer.concat([body, sshString(signature)]);
return `${CERT_TYPE} ${cert.toString("base64")} ${opts.keyId}`;
}
@@ -1,89 +0,0 @@
// Pure, dependency-free helpers for SSH public-key parsing/classification used
// by the Termix ID routes. Kept separate so they can be unit-tested in isolation.
// Upper bound on an accepted public-key line. Public keys are tiny (an RSA-4096
// line is ~720 chars); this caps the value the unauthenticated resolver later
// streams, preventing a multi-MB blob being stored and amplified.
export const MAX_PUBLIC_KEY_LENGTH = 8192;
// Normalized algorithm groups, used both for storage and for the `/<ALGO>`
// resolver filter (mirrors sshid.io's RSA/ED25519/ECDSA suffixes).
export const ALGO_GROUPS: Record<string, string> = {
"ssh-rsa": "RSA",
"rsa-sha2-256": "RSA",
"rsa-sha2-512": "RSA",
"ssh-dss": "DSA",
"ssh-ed25519": "ED25519",
"sk-ssh-ed25519@openssh.com": "ED25519-SK",
"ecdsa-sha2-nistp256": "ECDSA",
"ecdsa-sha2-nistp384": "ECDSA",
"ecdsa-sha2-nistp521": "ECDSA",
"sk-ecdsa-sha2-nistp256@openssh.com": "ECDSA-SK",
};
export function classifyAlgo(type: string): string {
if (ALGO_GROUPS[type]) return ALGO_GROUPS[type];
if (type.startsWith("ecdsa-")) return "ECDSA";
if (type.includes("ed25519")) return "ED25519";
if (type.includes("rsa")) return "RSA";
if (type.includes("dss") || type.includes("dsa")) return "DSA";
return type.toUpperCase();
}
export interface ParsedPublicKey {
type: string;
algorithm: string;
comment: string;
normalized: string; // "<type> <base64blob>"
}
/**
* Parse and validate a single OpenSSH public key line. Returns null when the
* input is not a well-formed public key.
*/
export function parsePublicKey(
raw: string | null | undefined,
): ParsedPublicKey | null {
if (typeof raw !== "string") return null;
if (raw.length > MAX_PUBLIC_KEY_LENGTH) return null;
const line = raw.trim().replace(/\s+/g, " ");
if (!line) return null;
const parts = line.split(" ");
if (parts.length < 2) return null;
const [type, blob, ...rest] = parts;
if (!/^[A-Za-z0-9@.-]+$/.test(type)) return null;
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(blob)) return null;
const decoded = Buffer.from(blob, "base64");
// The blob is an SSH wire-format string whose first field repeats the type.
if (decoded.length < 8) return null;
try {
const len = decoded.readUInt32BE(0);
if (len <= 0 || len > 64 || 4 + len > decoded.length) return null;
const embeddedType = decoded.toString("utf8", 4, 4 + len);
if (embeddedType !== type) return null;
} catch {
return null;
}
return {
type,
algorithm: classifyAlgo(type),
comment: rest.join(" "),
normalized: `${type} ${blob}`,
};
}
/**
* Whether a key's normalized algorithm group matches a `/<ALGO>` resolver
* filter. Exact match only — `ED25519` must NOT also return `ED25519-SK`.
*/
export function matchesAlgoFilter(
algorithm: string,
filter: string | null,
): boolean {
if (!filter) return true;
return algorithm.toUpperCase() === filter.toUpperCase();
}
File diff suppressed because it is too large. Load diff
+125
View File
@@ -13,13 +13,19 @@
* "op://") instead of handing over a host's own stored secret. Needs
* auth:provide, like ctx.auth's registrations, and the scheme must be listed
* in contributes.auth.secretSchemes.
*
* listSshKeys and createSshKey reach the acting user's own saved keys: the
* public half only (credentials:use), and saving a new key pair
* (credentials:write). Termix Identity publishes and generates keys this way.
*/
import type {
PluginCredentials,
PluginHostProtocol,
PluginProtocolTarget,
PluginSshKeyCredential,
} from "@termix/plugin-sdk/backend";
import ssh2 from "ssh2";
import { PluginCapabilityError } from "@termix/plugin-sdk/backend";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import { assertCapability } from "./permissions.js";
@@ -157,6 +163,47 @@ async function resolveRecipientAuth(
};
}
function derivePublicKey(
privateKey: string | null | undefined,
passphrase: string | null | undefined,
): string | null {
if (!privateKey) return null;
try {
const parsed = ssh2.utils.parseKey(privateKey, passphrase || undefined);
const key = Array.isArray(parsed) ? parsed[0] : parsed;
if (!key || key instanceof Error) return null;
return `${key.type} ${key.getPublicSSH().toString("base64")}`;
} catch {
return null;
}
}
function errorText(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
async function requireCorePermission(
userId: string,
permission: string,
): Promise<void> {
const { PermissionManager } = await import("../utils/permission-manager.js");
if (
!(await PermissionManager.getInstance().hasPermission(userId, permission))
) {
throw new Error(`The acting user lacks ${permission}`);
}
}
function actingUser(): string {
const userId = getActor();
if (!userId) {
throw new Error(
"ctx.credentials needs an acting user: call it inside a request or ctx.asUser",
);
}
return userId;
}
export function createPluginCredentials({
manifest,
bag,
@@ -166,6 +213,84 @@ export function createPluginCredentials({
const declared = manifest.capabilities;
return {
listSshKeys: async () => {
try {
await assertCapability(pluginId, "credentials:use", declared);
const userId = actingUser();
await requireCorePermission(userId, "credentials.view");
const { createCurrentCredentialRepository } =
await import("../database/repositories/factory.js");
const rows =
await createCurrentCredentialRepository().listDecryptedByUserId(
userId,
);
const keys: PluginSshKeyCredential[] = rows
.filter((row) => row.authType === "key")
.map((row) => ({
id: row.id,
name: row.name,
username: row.username ?? null,
publicKey:
(typeof row.publicKey === "string" && row.publicKey.trim()) ||
derivePublicKey(row.privateKey || row.key, row.keyPassword),
}));
await audit("credentials_list_keys", `${keys.length} keys`, {
success: true,
});
return keys;
} catch (error) {
await audit("credentials_list_keys", "saved SSH keys", {
success: false,
errorMessage: errorText(error),
});
throw error;
}
},
createSshKey: async (input) => {
const details = `SSH key "${input?.name ?? ""}"`;
try {
await assertCapability(pluginId, "credentials:write", declared);
const userId = actingUser();
await requireCorePermission(userId, "credentials.create");
if (!input?.name || !input.privateKey || !input.publicKey) {
throw new Error("name, privateKey and publicKey are required");
}
const { createCurrentCredentialRepository } =
await import("../database/repositories/factory.js");
const created =
await createCurrentCredentialRepository().createEncryptedForUser(
userId,
{
userId,
name: input.name,
description: input.description ?? null,
folder: null,
tags: "",
authType: "key",
username: input.username || null,
password: null,
key: input.privateKey,
privateKey: input.privateKey,
publicKey: input.publicKey,
keyPassword: null,
keyType: null,
detectedKeyType: input.keyType,
usageCount: 0,
lastUsed: null,
},
);
await audit("credentials_create_key", details, { success: true });
return { id: created.id };
} catch (error) {
await audit("credentials_create_key", details, {
success: false,
errorMessage: errorText(error),
});
throw error;
}
},
registerSecretResolver: (scheme, resolve) => {
if (!declared.includes("auth:provide")) {
throw new PluginCapabilityError(pluginId, "auth:provide");
+6
View File
@@ -392,6 +392,12 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
const { runTotpMigration } =
await import("./utils/crypto-migration/totp-migration.js");
await runTotpMigration();
// Also after the plugin moves, for a CA whose owner key was not open
// when the table was adopted.
const { runTermixIdentityCaMigration } =
await import("./utils/crypto-migration/termix-identity-ca-migration.js");
await runTermixIdentityCaMigration();
} catch (error) {
systemLogger.warn("Plugin runtime failed to initialize", {
operation: "plugin_init",
@@ -69,8 +69,9 @@ describe("generated schemas", () => {
expect(tablesOf(pgSchema)).toEqual(tablesOf(sqliteSchema));
expect(tablesOf(mysqlSchema)).toEqual(tablesOf(sqliteSchema));
// Guard against a generator that silently emits nothing.
expect(tablesOf(sqliteSchema).length).toBeGreaterThan(40);
// Guard against a generator that silently emits nothing. Core keeps
// shrinking as feature tables move into plugins, so this stays loose.
expect(tablesOf(sqliteSchema).length).toBeGreaterThan(25);
});
it("maps each column to the right storage type per dialect", () => {
@@ -1,224 +0,0 @@
import { sql } from "drizzle-orm";
import { afterEach, describe, expect, vi } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { DataCrypto } from "../../../utils/data-crypto.js";
import { TermixIdentityCaRepository } from "../../../database/repositories/termix-identity-ca-repository.js";
describe("TermixIdentityCaRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
vi.restoreAllMocks();
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(onWrite = vi.fn()): Promise<{
repo: TermixIdentityCaRepository;
onWrite: ReturnType<typeof vi.fn>;
}> {
adapter = new TestSqliteDatabase();
const context = await adapter.connect();
await adapter.exec(`
INSERT INTO users (id, username, password_hash)
VALUES ('user-1', 'alice', 'hash');
INSERT INTO termix_identities (id, user_id, handle)
VALUES (7, 'user-1', 'alice');
`);
return {
repo: new TermixIdentityCaRepository(context, onWrite),
onWrite,
};
}
function mockCrypto(): void {
vi.spyOn(DataCrypto, "validateUserAccess").mockReturnValue(
Buffer.from("user-key"),
);
vi.spyOn(DataCrypto, "getUserDataKey").mockReturnValue(
Buffer.from("user-key"),
);
vi.spyOn(DataCrypto, "encryptRecord").mockImplementation(
(_tableName, record) =>
({
...record,
privateKey: "encrypted-ca-private",
}) as typeof record,
);
vi.spyOn(DataCrypto, "decryptRecord").mockImplementation(
(_tableName, record) =>
({
...record,
privateKey:
record.privateKey === "encrypted-ca-private"
? "decrypted-ca-private"
: record.privateKey,
}) as typeof record,
);
}
it("creates CA private keys with the real row id before encryption", async () => {
const { repo, onWrite } = await createRepository();
mockCrypto();
const created = await repo.createEncryptedForUser("user-1", {
identityId: 7,
userId: "user-1",
publicKey: "ssh-ed25519 public",
privateKey: "plain-ca-private",
validityDays: 120,
});
const [raw] = (await adapter!.query(
sql`SELECT id, public_key, private_key, validity_days FROM termix_identity_ca WHERE identity_id = 7`,
)) as {
id: number;
public_key: string;
private_key: string;
validity_days: number;
}[];
expect(created.privateKey).toBe("decrypted-ca-private");
expect(raw.private_key).toBe("encrypted-ca-private");
expect(raw.public_key).toBe("ssh-ed25519 public");
expect(raw.validity_days).toBe(120);
expect(DataCrypto.encryptRecord).toHaveBeenCalledWith(
"termix_identity_ca",
{ id: raw.id, privateKey: "plain-ca-private" },
"user-1",
Buffer.from("user-key"),
);
expect(onWrite).toHaveBeenCalledTimes(1);
});
it("reads public CA metadata without decrypting private key material", async () => {
const { repo } = await createRepository();
const decryptSpy = vi.spyOn(DataCrypto, "decryptRecord");
await adapter!.run(
sql`INSERT INTO termix_identity_ca (identity_id, user_id, public_key, private_key, validity_days)
VALUES (7, 'user-1', 'ssh-ed25519 public', 'encrypted-ca-private', 45)`,
);
await expect(repo.findPublicByIdentityId(7)).resolves.toEqual({
publicKey: "ssh-ed25519 public",
validityDays: 45,
});
expect(decryptSpy).not.toHaveBeenCalled();
});
it("decrypts CA private keys through the user data boundary", async () => {
const { repo } = await createRepository();
mockCrypto();
await adapter!.run(
sql`INSERT INTO termix_identity_ca (identity_id, user_id, public_key, private_key, validity_days)
VALUES (7, 'user-1', 'ssh-ed25519 public', 'encrypted-ca-private', 45)`,
);
const ca = await repo.findDecryptedByIdentityId("user-1", 7);
expect(ca).toMatchObject({
identityId: 7,
publicKey: "ssh-ed25519 public",
privateKey: "decrypted-ca-private",
validityDays: 45,
});
expect(DataCrypto.decryptRecord).toHaveBeenCalledWith(
"termix_identity_ca",
expect.objectContaining({ identityId: 7 }),
"user-1",
Buffer.from("user-key"),
);
});
it("updates CA private keys through encrypted writes", async () => {
const { repo, onWrite } = await createRepository();
mockCrypto();
await adapter!.run(
sql`INSERT INTO termix_identity_ca (identity_id, user_id, public_key, private_key, validity_days) VALUES (7, 'user-1', 'ssh-ed25519 old', 'encrypted-ca-private', 45)`,
);
onWrite.mockClear();
const updated = await repo.updateEncryptedForIdentity("user-1", 7, {
publicKey: "ssh-ed25519 new",
privateKey: "plain-updated-ca-private",
validityDays: 90,
});
const [raw] = (await adapter!.query(
sql`SELECT public_key, private_key, validity_days FROM termix_identity_ca WHERE identity_id = 7`,
)) as {
public_key: string;
private_key: string;
validity_days: number;
}[];
expect(updated).toMatchObject({
publicKey: "ssh-ed25519 new",
privateKey: "decrypted-ca-private",
validityDays: 90,
});
expect(raw).toEqual({
public_key: "ssh-ed25519 new",
private_key: "encrypted-ca-private",
validity_days: 90,
});
expect(DataCrypto.encryptRecord).toHaveBeenCalledWith(
"termix_identity_ca",
expect.objectContaining({
privateKey: "plain-updated-ca-private",
}),
"user-1",
Buffer.from("user-key"),
);
expect(onWrite).toHaveBeenCalledTimes(1);
});
it("deletes CA rows through the write boundary", async () => {
const { repo, onWrite } = await createRepository();
await adapter!.run(
sql`INSERT INTO termix_identity_ca (identity_id, user_id, public_key, private_key, validity_days) VALUES (7, 'user-1', 'ssh-ed25519 public', 'encrypted-ca-private', 45)`,
);
onWrite.mockClear();
await expect(repo.deleteByIdentityId(7)).resolves.toBe(true);
await expect(repo.deleteByIdentityId(7)).resolves.toBe(false);
expect(
(
await adapter!.query(
sql`SELECT COUNT(*) AS count FROM termix_identity_ca`,
)
).map((row) => Number((row as { count: unknown }).count)),
).toEqual([0]);
expect(onWrite).toHaveBeenCalledTimes(1);
});
it("deletes CA rows for a user", async () => {
const { repo, onWrite } = await createRepository();
await adapter!.run(
sql`INSERT INTO users (id, username, password_hash) VALUES ('user-2', 'bob', 'hash')`,
);
await adapter!.run(
sql`INSERT INTO termix_identities (id, user_id, handle) VALUES (8, 'user-2', 'bob')`,
);
await adapter!.run(
sql`INSERT INTO termix_identity_ca (identity_id, user_id, public_key, private_key, validity_days) VALUES (7, 'user-1', 'ssh-ed25519 public', 'encrypted-ca-private', 45)`,
);
await adapter!.run(
sql`INSERT INTO termix_identity_ca (identity_id, user_id, public_key, private_key, validity_days) VALUES (8, 'user-2', 'ssh-ed25519 other', 'encrypted-other', 90)`,
);
onWrite.mockClear();
await expect(repo.deleteByUserId("user-1")).resolves.toBe(1);
await expect(repo.deleteByUserId("missing")).resolves.toBe(0);
expect(
await adapter!.query(
sql`SELECT user_id, public_key FROM termix_identity_ca ORDER BY user_id`,
),
).toEqual([{ user_id: "user-2", public_key: "ssh-ed25519 other" }]);
expect(onWrite).toHaveBeenCalledTimes(1);
});
});
@@ -1,170 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { TermixIdentityRepository } from "../../../database/repositories/termix-identity-repository.js";
describe("TermixIdentityRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(onWrite = vi.fn()): Promise<{
repo: TermixIdentityRepository;
onWrite: ReturnType<typeof vi.fn>;
}> {
adapter = new TestSqliteDatabase();
const context = await adapter.connect();
await adapter.exec(`
INSERT INTO users (id, username, password_hash)
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
INSERT INTO ssh_credentials (id, user_id, name, username, auth_type) VALUES
(10, 'user-1', 'cred-10', 'root', 'password'),
(20, 'user-1', 'cred-20', 'root', 'password');
`);
return {
repo: new TermixIdentityRepository(context, onWrite),
onWrite,
};
}
it("creates, updates, finds, and deletes identities", async () => {
const { repo, onWrite } = await createRepository();
const created = await repo.createIdentity({
userId: "user-1",
handle: "alice",
description: "workstation keys",
});
expect(created.id).toBeGreaterThan(0);
expect(await repo.isHandleTaken("alice")).toBe(true);
expect(await repo.findIdentityForUser("user-1")).toMatchObject({
handle: "alice",
});
expect(await repo.findIdentityByHandle("alice")).toMatchObject({
userId: "user-1",
});
const updated = await repo.updateIdentityForUser("user-1", {
handle: "alice-renamed",
description: null,
});
expect(updated).toMatchObject({
handle: "alice-renamed",
description: null,
});
await expect(repo.deleteIdentityForUser("user-1")).resolves.toBe(true);
await expect(repo.deleteIdentityForUser("user-1")).resolves.toBe(false);
await expect(repo.findIdentityForUser("user-1")).resolves.toBeNull();
expect(onWrite).toHaveBeenCalledTimes(3);
});
it("creates, lists, updates, deletes, and links public keys", async () => {
const { repo, onWrite } = await createRepository();
const identity = await repo.createIdentity({
userId: "user-1",
handle: "alice",
description: null,
});
onWrite.mockClear();
const first = await repo.createKey({
identityId: identity.id,
userId: "user-1",
publicKey: "ssh-ed25519 AAAA1",
keyType: "ssh-ed25519",
algorithm: "ED25519",
label: "laptop",
comment: null,
source: "credential",
credentialId: 10,
});
await repo.createKey({
identityId: identity.id,
userId: "user-1",
publicKey: "ssh-rsa AAAA2",
keyType: "ssh-rsa",
algorithm: "RSA",
label: "disabled",
comment: null,
source: "manual",
credentialId: 20,
enabled: false,
});
expect(await repo.listKeysByIdentityId(identity.id)).toHaveLength(2);
expect(await repo.listEnabledKeysByIdentityId(identity.id)).toMatchObject([
{ id: first.id, publicKey: "ssh-ed25519 AAAA1" },
]);
expect(await repo.listLinkedCredentialIds(identity.id)).toEqual([10]);
const updated = await repo.updateKeyForUser("user-1", first.id, {
enabled: false,
label: "revoked",
});
expect(updated).toMatchObject({ enabled: false, label: "revoked" });
await expect(
repo.findKeyForUser("user-1", first.id),
).resolves.toMatchObject({ label: "revoked" });
await expect(repo.deleteKeyForUser("user-1", first.id)).resolves.toBe(true);
await expect(repo.deleteKeyForUser("user-1", first.id)).resolves.toBe(
false,
);
expect(onWrite).toHaveBeenCalledTimes(4);
});
it("deletes identities and keys for a user", async () => {
const { repo, onWrite } = await createRepository();
const userIdentity = await repo.createIdentity({
userId: "user-1",
handle: "alice",
description: null,
});
const otherIdentity = await repo.createIdentity({
userId: "user-2",
handle: "bob",
description: null,
});
await repo.createKey({
identityId: userIdentity.id,
userId: "user-1",
publicKey: "ssh-ed25519 AAAA1",
keyType: "ssh-ed25519",
algorithm: "ED25519",
source: "manual",
});
await repo.createKey({
identityId: otherIdentity.id,
userId: "user-2",
publicKey: "ssh-ed25519 AAAA2",
keyType: "ssh-ed25519",
algorithm: "ED25519",
source: "manual",
});
onWrite.mockClear();
await expect(repo.deleteByUserId("user-1")).resolves.toEqual({
identitiesDeleted: 1,
keysDeleted: 1,
});
await expect(repo.deleteByUserId("missing")).resolves.toEqual({
identitiesDeleted: 0,
keysDeleted: 0,
});
expect(await repo.findIdentityForUser("user-1")).toBeNull();
expect(await repo.listKeysByIdentityId(userIdentity.id)).toEqual([]);
expect(await repo.findIdentityForUser("user-2")).toMatchObject({
handle: "bob",
});
expect(await repo.listKeysByIdentityId(otherIdentity.id)).toHaveLength(1);
expect(onWrite).toHaveBeenCalledTimes(1);
});
});
@@ -1,6 +1,7 @@
/**
* The OPKSSH, Step CA and Vault redirect URIs identity providers were registered with before
* 2.9 must keep reaching the plugin's callback, query intact.
* 2.9 must keep reaching the plugin's callback, query intact, and the Termix
* ID resolver URLs servers fetch must keep reaching the plugin's resolver.
*/
import http from "node:http";
@@ -8,7 +9,7 @@ import type { AddressInfo } from "node:net";
import express from "express";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
const { registerHostAuthCompatRoutes } =
const { registerHostAuthCompatRoutes, registerTermixIdCompatRoutes } =
await import("../../../database/routes/host-compat-routes.js");
let server: http.Server;
@@ -19,6 +20,9 @@ beforeEach(async () => {
registerHostAuthCompatRoutes(router);
const app = express();
app.use("/host", router);
const termixId = express.Router();
registerTermixIdCompatRoutes(termixId);
app.use("/termix-id", termixId);
server = http.createServer(app);
await new Promise<void>((resolve) => server.listen(0, resolve));
base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
@@ -88,3 +92,25 @@ describe("the old Vault callback", () => {
}
});
});
describe("the old Termix ID resolver URLs", () => {
it.each([
["/termix-id/u/alice", "/plugin-api/termix-identity/u/alice"],
[
"/termix-id/u/alice/ED25519",
"/plugin-api/termix-identity/u/alice/ED25519",
],
["/termix-id/u/alice/ca", "/plugin-api/termix-identity/u/alice/ca"],
])("permanently redirects %s", async (path, target) => {
const response = await fetch(`${base}${path}`, { redirect: "manual" });
expect(response.status).toBe(308);
expect(response.headers.get("location")).toBe(target);
});
it("does not forward the old management routes", async () => {
const response = await fetch(`${base}/termix-id/me`, {
redirect: "manual",
});
expect(response.status).toBe(404);
});
});
@@ -1,127 +0,0 @@
import { describe, it, expect } from "vitest";
import crypto from "crypto";
import fs from "fs";
import os from "os";
import path from "path";
import { execFileSync } from "child_process";
import {
generateCa,
signUserCertificate,
ed25519RawFromLine,
} from "../../../database/routes/ssh-certificate.js";
function publicKeyObjectFromLine(line: string) {
const raw = ed25519RawFromLine(line);
if (!raw) throw new Error("not ed25519");
return crypto.createPublicKey({
key: { kty: "OKP", crv: "Ed25519", x: raw.toString("base64url") },
format: "jwk",
});
}
// Split a cert blob into the signed body and the raw 64-byte ed25519 signature.
function splitCert(certLine: string): { body: Buffer; rawSig: Buffer } {
const blob = Buffer.from(certLine.split(/\s+/)[1], "base64");
// trailing signature string = str( str("ssh-ed25519") + str(64-byte sig) )
const sigBlobLen = 4 + "ssh-ed25519".length + 4 + 64; // 83
const body = blob.subarray(0, blob.length - (4 + sigBlobLen));
const rawSig = blob.subarray(blob.length - 64);
return { body, rawSig };
}
describe("generateCa", () => {
it("produces a valid ed25519 public line and PKCS8 private key", () => {
const ca = generateCa();
expect(ca.publicKeyLine.startsWith("ssh-ed25519 ")).toBe(true);
expect(ed25519RawFromLine(ca.publicKeyLine)?.length).toBe(32);
expect(ca.privateKeyPem).toContain("BEGIN PRIVATE KEY");
// The PEM must load as a usable signing key.
expect(() =>
crypto.createPrivateKey({
key: ca.privateKeyPem,
format: "pem",
type: "pkcs8",
}),
).not.toThrow();
});
});
describe("signUserCertificate", () => {
it("returns null for non-ed25519 user keys", () => {
const ca = generateCa();
const cert = signUserCertificate({
userPublicKeyLine: "ssh-rsa AAAAB3Nz",
caPrivateKeyPem: ca.privateKeyPem,
caPublicKeyLine: ca.publicKeyLine,
keyId: "x",
principals: [],
validAfter: 0,
validBefore: 1,
});
expect(cert).toBeNull();
});
it("produces a cert whose signature verifies against the CA key", () => {
const ca = generateCa();
const user = generateCa(); // reuse: a valid ed25519 public line
const cert = signUserCertificate({
userPublicKeyLine: user.publicKeyLine,
caPrivateKeyPem: ca.privateKeyPem,
caPublicKeyLine: ca.publicKeyLine,
keyId: "termix:@alice",
principals: ["root", "ubuntu"],
validAfter: 1000,
validBefore: 2000,
});
expect(cert).not.toBeNull();
expect(cert!.startsWith("ssh-ed25519-cert-v01@openssh.com ")).toBe(true);
const { body, rawSig } = splitCert(cert!);
const caPub = publicKeyObjectFromLine(ca.publicKeyLine);
expect(crypto.verify(null, body, caPub, rawSig)).toBe(true);
// A different CA must NOT verify.
const otherPub = publicKeyObjectFromLine(generateCa().publicKeyLine);
expect(crypto.verify(null, body, otherPub, rawSig)).toBe(false);
});
it("is accepted and correctly parsed by ssh-keygen -L", () => {
let sshKeygen: string;
try {
sshKeygen = execFileSync("ssh-keygen", ["--help"], { encoding: "utf8" });
void sshKeygen;
} catch (e) {
// ssh-keygen prints usage to stderr and exits non-zero for --help; that's
// fine — it means the binary exists. Only skip if it's truly missing.
if ((e as { code?: string }).code === "ENOENT") return;
}
const ca = generateCa();
const user = generateCa();
const now = Math.floor(Date.now() / 1000);
const cert = signUserCertificate({
userPublicKeyLine: user.publicKeyLine,
caPrivateKeyPem: ca.privateKeyPem,
caPublicKeyLine: ca.publicKeyLine,
keyId: "termix-test-id",
principals: ["deploy"],
validAfter: now,
validBefore: now + 3600,
});
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-cert-"));
const file = path.join(dir, "id-cert.pub");
try {
fs.writeFileSync(file, cert + "\n");
const out = execFileSync("ssh-keygen", ["-L", "-f", file], {
encoding: "utf8",
});
expect(out).toContain("user certificate");
expect(out).toContain('Key ID: "termix-test-id"');
expect(out).toContain("deploy");
expect(out).toMatch(/permit-pty/);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
});
@@ -1,95 +0,0 @@
import { describe, it, expect } from "vitest";
import {
classifyAlgo,
parsePublicKey,
matchesAlgoFilter,
MAX_PUBLIC_KEY_LENGTH,
} from "../../../database/routes/termix-id-keys.js";
// Build a valid OpenSSH public-key line for a given type by encoding a wire
// blob whose first string field equals the type (what parsePublicKey checks).
function makeKey(type: string, comment = ""): string {
const typeBuf = Buffer.from(type, "utf8");
const header = Buffer.alloc(4);
header.writeUInt32BE(typeBuf.length, 0);
const body = Buffer.alloc(40); // arbitrary trailing key material
const blob = Buffer.concat([header, typeBuf, body]).toString("base64");
return `${type} ${blob}${comment ? ` ${comment}` : ""}`;
}
describe("classifyAlgo", () => {
it("maps known types to normalized groups", () => {
expect(classifyAlgo("ssh-rsa")).toBe("RSA");
expect(classifyAlgo("rsa-sha2-512")).toBe("RSA");
expect(classifyAlgo("ssh-ed25519")).toBe("ED25519");
expect(classifyAlgo("ecdsa-sha2-nistp256")).toBe("ECDSA");
expect(classifyAlgo("ssh-dss")).toBe("DSA");
expect(classifyAlgo("sk-ssh-ed25519@openssh.com")).toBe("ED25519-SK");
expect(classifyAlgo("sk-ecdsa-sha2-nistp256@openssh.com")).toBe("ECDSA-SK");
});
it("falls back by substring for unknown variants", () => {
expect(classifyAlgo("ecdsa-sha2-nistp999")).toBe("ECDSA");
expect(classifyAlgo("rsa-sha2-256-cert")).toBe("RSA");
expect(classifyAlgo("something-weird")).toBe("SOMETHING-WEIRD");
});
});
describe("parsePublicKey", () => {
it("parses a valid ed25519 key and extracts the comment", () => {
const parsed = parsePublicKey(makeKey("ssh-ed25519", "alice@laptop"));
expect(parsed).not.toBeNull();
expect(parsed?.type).toBe("ssh-ed25519");
expect(parsed?.algorithm).toBe("ED25519");
expect(parsed?.comment).toBe("alice@laptop");
// Comment is stripped from the normalized (dedupe) form.
expect(parsed?.normalized.includes("alice@laptop")).toBe(false);
});
it("parses SK (FIDO) key types", () => {
expect(
parsePublicKey(makeKey("sk-ssh-ed25519@openssh.com"))?.algorithm,
).toBe("ED25519-SK");
});
it.each([
[null],
[undefined],
[""],
[" "],
["ssh-ed25519"], // missing blob
["ssh-ed25519 not_base64!!"], // bad base64 charset
["ssh-rsa AAAAB3Nz"], // blob whose embedded type != declared type
])("rejects malformed input %p", (input) => {
expect(parsePublicKey(input as string)).toBeNull();
});
it("rejects an over-length line (amplification guard)", () => {
const valid = makeKey("ssh-ed25519");
const padded = valid + " " + "A".repeat(MAX_PUBLIC_KEY_LENGTH);
expect(padded.length).toBeGreaterThan(MAX_PUBLIC_KEY_LENGTH);
expect(parsePublicKey(padded)).toBeNull();
});
it("rejects a blob whose embedded type does not match the prefix", () => {
// Declared ssh-rsa but the wire blob says ssh-ed25519.
const blob = makeKey("ssh-ed25519").split(" ")[1];
expect(parsePublicKey(`ssh-rsa ${blob}`)).toBeNull();
});
});
describe("matchesAlgoFilter", () => {
it("returns all keys when no filter", () => {
expect(matchesAlgoFilter("ED25519", null)).toBe(true);
});
it("matches exactly and is case-insensitive", () => {
expect(matchesAlgoFilter("ED25519", "ed25519")).toBe(true);
expect(matchesAlgoFilter("RSA", "RSA")).toBe(true);
});
it("does NOT let ED25519 match ED25519-SK (the over-match bug)", () => {
expect(matchesAlgoFilter("ED25519-SK", "ED25519")).toBe(false);
expect(matchesAlgoFilter("ECDSA-SK", "ECDSA")).toBe(false);
});
});
@@ -1,162 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const mockSelect = vi.fn();
const mockUpdate = vi.fn();
const mockInsert = vi.fn();
const mockDelete = vi.fn();
vi.mock("../../../database/db/index.js", () => ({
db: {
select: mockSelect,
update: mockUpdate,
insert: mockInsert,
delete: mockDelete,
},
}));
vi.mock("../../../utils/logger.js", () => ({
apiLogger: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), success: vi.fn() },
authLogger: {
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
success: vi.fn(),
},
}));
vi.mock("../../../utils/auth-manager.js", () => ({
AuthManager: {
getInstance: () => ({
createAuthMiddleware:
() =>
(req: Record<string, unknown>, _res: unknown, next: () => void) => {
req.userId = "user-1";
next();
},
createDataAccessMiddleware:
() => (_req: unknown, _res: unknown, next: () => void) =>
next(),
}),
},
}));
vi.mock("../../../utils/audit-logger.js", () => ({
logAudit: vi.fn(),
getRequestMeta: vi.fn(() => ({})),
}));
vi.mock("../../../utils/data-crypto.js", () => ({
DataCrypto: { getInstance: () => ({ encrypt: vi.fn(), decrypt: vi.fn() }) },
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentTermixIdentityCaRepository: vi.fn(() => ({
findPublicByIdentityId: vi.fn(),
findDecryptedByIdentityId: vi.fn(),
createEncryptedForUser: vi.fn(),
updateEncryptedForIdentity: vi.fn(),
deleteByIdentityId: vi.fn(),
})),
createCurrentTermixIdentityRepository: vi.fn(() => ({
findIdentityForUser: vi.fn(),
findIdentityByHandle: vi.fn(),
isHandleTaken: vi.fn(),
createIdentity: vi.fn(),
updateIdentityForUser: vi.fn(),
deleteIdentityForUser: vi.fn(),
listKeysByIdentityId: vi.fn(),
listEnabledKeysByIdentityId: vi.fn(),
listLinkedCredentialIds: vi.fn(),
createKey: vi.fn(),
updateKeyForUser: vi.fn(),
deleteKeyForUser: vi.fn(),
findKeyForUser: vi.fn(),
})),
}));
vi.mock("../../../database/routes/termix-id-keys.js", () => ({
termixIdKeysRouter: { use: vi.fn() },
matchesAlgoFilter: vi.fn(() => true),
}));
// Chainable Drizzle stub — supports arbitrary method chains and resolves via .then()
function makeChain(resolveValue: unknown) {
const chain: Record<string, unknown> = {};
const methods = [
"from",
"where",
"set",
"values",
"returning",
"orderBy",
"limit",
"and",
"eq",
];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
(chain as unknown as Promise<unknown>).then = (
cb: (v: unknown) => unknown,
eb?: (e: unknown) => unknown,
) => Promise.resolve(resolveValue).then(cb, eb);
(chain as unknown as Promise<unknown>).catch = (
cb: (e: unknown) => unknown,
) => Promise.resolve(resolveValue).catch(cb);
return chain;
}
const IDENTITY_ROW = { id: 42, userId: "user-1", handle: "alice" };
describe("GET /termix-id/linked-credentials", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns empty list when user has no identity", async () => {
// First select (getIdentityForUser) returns nothing; second should not be called
mockSelect.mockReturnValueOnce(makeChain([]));
const { default: router } =
await import("../../../database/routes/termix-id.js");
expect(router).toBeDefined();
expect(router).toBeDefined();
}, 15_000);
it("returns empty list when identity has no keys", async () => {
mockSelect
.mockReturnValueOnce(makeChain([IDENTITY_ROW])) // identity lookup
.mockReturnValueOnce(makeChain([])); // keys lookup
const { default: router } =
await import("../../../database/routes/termix-id.js");
expect(router).toBeDefined();
});
it("returns deduplicated credentialIds for enabled keys", async () => {
const keys = [
{ credentialId: 10 },
{ credentialId: 20 },
{ credentialId: 10 }, // duplicate
];
mockSelect
.mockReturnValueOnce(makeChain([IDENTITY_ROW]))
.mockReturnValueOnce(makeChain(keys));
const { default: router } =
await import("../../../database/routes/termix-id.js");
expect(router).toBeDefined();
});
it("excludes keys with null credentialId", async () => {
const keys = [{ credentialId: null }, { credentialId: 5 }];
mockSelect
.mockReturnValueOnce(makeChain([IDENTITY_ROW]))
.mockReturnValueOnce(makeChain(keys));
const { default: router } =
await import("../../../database/routes/termix-id.js");
expect(router).toBeDefined();
});
});
@@ -15,6 +15,9 @@ const state = vi.hoisted(() => ({
canConnect: true,
resolution: null as Record<string, unknown> | null,
resolverCalls: [] as Array<Record<string, unknown>>,
savedCredentials: [] as Array<Record<string, unknown>>,
userCredentials: new Map<string, Array<Record<string, unknown>>>(),
corePermissions: new Set<string>(["credentials.view", "credentials.create"]),
}));
vi.mock("../../database/repositories/factory.js", () => ({
@@ -25,6 +28,18 @@ vi.mock("../../database/repositories/factory.js", () => ({
capability,
})),
}),
createCurrentCredentialRepository: () => ({
listDecryptedByUserId: async (userId: string) =>
state.userCredentials.get(userId) ?? [],
createEncryptedForUser: async (
userId: string,
data: Record<string, unknown>,
) => {
const row = { ...data, id: 100 + state.savedCredentials.length, userId };
state.savedCredentials.push(row);
return row;
},
}),
createCurrentHostResolutionRepository: () => ({
findHostOwnerId: async (hostId: number) =>
(state.hosts.get(hostId)?.userId as string) ?? null,
@@ -44,6 +59,8 @@ vi.mock("../../utils/permission-manager.js", () => ({
PermissionManager: {
getInstance: () => ({
canAccessHost: async () => ({ hasAccess: state.canConnect }),
hasPermission: async (_userId: string, permission: string) =>
state.corePermissions.has(permission),
}),
},
}));
@@ -66,6 +83,7 @@ import {
resetSecretResolverRegistryForTests,
} from "../../hosts/connect/secret-resolver-registry.js";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import ssh2 from "ssh2";
function contextFor(
capabilities: string[],
@@ -112,6 +130,9 @@ beforeEach(() => {
state.canConnect = true;
state.resolution = null;
state.resolverCalls.length = 0;
state.savedCredentials.length = 0;
state.userCredentials.clear();
state.corePermissions = new Set(["credentials.view", "credentials.create"]);
state.hosts.set(7, { ...baseHost });
resetSecretResolverRegistryForTests();
});
@@ -305,3 +326,120 @@ describe("ctx.credentials.registerSecretResolver", () => {
expect(getSecretResolver("op")).toBeUndefined();
});
});
describe("ctx.credentials.listSshKeys", () => {
const pair = ssh2.utils.generateKeyPairSync("ed25519");
const derived = (() => {
const parsed = ssh2.utils.parseKey(pair.private);
const key = Array.isArray(parsed) ? parsed[0] : parsed;
if (key instanceof Error) throw key;
return `${key.type} ${key.getPublicSSH().toString("base64")}`;
})();
beforeEach(() => {
state.userCredentials.set("owner", [
{ id: 1, name: "stored", authType: "key", publicKey: "ssh-ed25519 AAAA" },
{ id: 2, name: "derived", authType: "key", privateKey: pair.private },
{ id: 3, name: "pw", authType: "password", password: "secret" },
]);
});
it("refuses without credentials:use", async () => {
grants.set("demo", []);
const ctx = contextFor([]);
await expect(
runAsActor("owner", "request", () => ctx.credentials.listSshKeys()),
).rejects.toThrow(/credentials:use/);
expect(auditEntries.at(-1)).toMatchObject({
action: "plugin_credentials_list_keys",
success: false,
});
});
it("refuses with no acting user", async () => {
grants.set("demo", ["credentials:use"]);
const ctx = contextFor(["credentials:use"]);
await expect(ctx.credentials.listSshKeys()).rejects.toThrow(/acting user/);
});
it("refuses without the core credentials.view permission", async () => {
grants.set("demo", ["credentials:use"]);
state.corePermissions.delete("credentials.view");
const ctx = contextFor(["credentials:use"]);
await expect(
runAsActor("owner", "request", () => ctx.credentials.listSshKeys()),
).rejects.toThrow(/credentials.view/);
});
it("lists only the actor's key credentials, public half only", async () => {
grants.set("demo", ["credentials:use"]);
const ctx = contextFor(["credentials:use"]);
const keys = await runAsActor("owner", "request", () =>
ctx.credentials.listSshKeys(),
);
expect(keys).toEqual([
{ id: 1, name: "stored", username: null, publicKey: "ssh-ed25519 AAAA" },
{ id: 2, name: "derived", username: null, publicKey: derived },
]);
expect(JSON.stringify(keys)).not.toContain("PRIVATE KEY");
const other = await runAsActor("someone", "request", () =>
ctx.credentials.listSshKeys(),
);
expect(other).toEqual([]);
});
});
describe("ctx.credentials.createSshKey", () => {
const input = {
name: "Generated",
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----",
publicKey: "ssh-ed25519 AAAA",
keyType: "ssh-ed25519",
};
it("refuses without credentials:write", async () => {
grants.set("demo", ["credentials:use"]);
const ctx = contextFor(["credentials:use"]);
await expect(
runAsActor("owner", "request", () => ctx.credentials.createSshKey(input)),
).rejects.toThrow(/credentials:write/);
expect(state.savedCredentials).toHaveLength(0);
expect(auditEntries.at(-1)).toMatchObject({
action: "plugin_credentials_create_key",
success: false,
});
});
it("refuses without the core credentials.create permission", async () => {
grants.set("demo", ["credentials:write"]);
state.corePermissions.delete("credentials.create");
const ctx = contextFor(["credentials:write"]);
await expect(
runAsActor("owner", "request", () => ctx.credentials.createSshKey(input)),
).rejects.toThrow(/credentials.create/);
expect(state.savedCredentials).toHaveLength(0);
});
it("saves a key credential owned by the actor", async () => {
grants.set("demo", ["credentials:write"]);
const ctx = contextFor(["credentials:write"]);
const result = await runAsActor("owner", "request", () =>
ctx.credentials.createSshKey({ ...input, username: "root" }),
);
expect(result).toEqual({ id: 100 });
expect(state.savedCredentials[0]).toMatchObject({
userId: "owner",
authType: "key",
name: "Generated",
username: "root",
privateKey: input.privateKey,
publicKey: input.publicKey,
detectedKeyType: "ssh-ed25519",
});
expect(auditEntries.at(-1)).toMatchObject({
action: "plugin_credentials_create_key",
success: true,
});
});
});
@@ -0,0 +1,114 @@
/**
* The 2.8 to 2.9 Termix ID CA move. A CA private key encrypted with the
* owner's data key must end up sealed with the installation key in the
* plugin's adopted table, readable, and a second run changes nothing.
*/
import crypto from "node:crypto";
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { FieldCrypto } from "../../utils/field-crypto.js";
const h = vi.hoisted(() => ({
db: null as unknown,
deks: new Map<string, Buffer>(),
}));
vi.mock("../../database/db/index.js", () => ({ getDb: () => h.db }));
vi.mock("../../utils/data-crypto.js", () => ({
DataCrypto: { getUserDataKey: (userId: string) => h.deks.get(userId) },
}));
vi.mock("../../utils/system-secret-crypto.js", () => ({
isSystemEncrypted: (value: string) => value.startsWith("sysenc:v1:"),
encryptSystemSecret: async (value: string) => `sysenc:v1:${value}`,
}));
vi.mock("../../utils/logger.js", () => {
const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
return { databaseLogger: log };
});
const { runTermixIdentityCaMigration } =
await import("../../utils/crypto-migration/termix-identity-ca-migration.js");
const PEM = "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----\n";
let sqlite: Database.Database;
function setup({ pluginTable }: { pluginTable: boolean }) {
sqlite = new Database(":memory:");
if (pluginTable) {
sqlite.exec(`
CREATE TABLE p_termix_identity_ca (
id INTEGER PRIMARY KEY AUTOINCREMENT,
identity_id INTEGER NOT NULL UNIQUE,
user_id TEXT NOT NULL,
public_key TEXT NOT NULL,
private_key TEXT NOT NULL,
validity_days INTEGER NOT NULL DEFAULT 90
);
`);
}
h.db = drizzle(sqlite);
}
function addCa(id: number, userId: string, withKey = true) {
const dek = crypto.randomBytes(32);
if (withKey) h.deks.set(userId, dek);
sqlite
.prepare(
"INSERT INTO p_termix_identity_ca (id, identity_id, user_id, public_key, private_key) VALUES (?, ?, ?, 'ssh-ed25519 AAAA', ?)",
)
.run(
id,
id,
userId,
FieldCrypto.encryptField(PEM, dek, String(id), "privateKey"),
);
}
const keyOf = (id: number) =>
(
sqlite
.prepare("SELECT private_key FROM p_termix_identity_ca WHERE id = ?")
.get(id) as { private_key: string }
).private_key;
beforeEach(() => {
h.deks.clear();
});
describe("runTermixIdentityCaMigration", () => {
it("does nothing before the plugin adopted the table", async () => {
setup({ pluginTable: false });
expect(await runTermixIdentityCaMigration()).toBe(0);
});
it("reseals a data-key CA with the installation key", async () => {
setup({ pluginTable: true });
addCa(1, "alice");
expect(await runTermixIdentityCaMigration()).toBe(1);
expect(keyOf(1)).toBe(`sysenc:v1:${PEM}`);
});
it("changes nothing on a second run", async () => {
setup({ pluginTable: true });
addCa(1, "alice");
await runTermixIdentityCaMigration();
const first = keyOf(1);
expect(await runTermixIdentityCaMigration()).toBe(0);
expect(keyOf(1)).toBe(first);
});
it("leaves a row whose owner's key is not open for the next run", async () => {
setup({ pluginTable: true });
addCa(1, "alice");
addCa(2, "bob", false);
const locked = keyOf(2);
expect(await runTermixIdentityCaMigration()).toBe(1);
expect(keyOf(2)).toBe(locked);
});
});
@@ -7,8 +7,10 @@
import { runLdapProviderMigration } from "./ldap-provider-migration.js";
import { runOpksshConfigMigration } from "./opkssh-config-migration.js";
import { runTermixIdentityCaMigration } from "./termix-identity-ca-migration.js";
export async function runPluginDataMoves(): Promise<void> {
await runLdapProviderMigration();
await runOpksshConfigMigration();
await runTermixIdentityCaMigration();
}
@@ -0,0 +1,77 @@
/**
* Reseals 2.8 Termix ID certificate authority keys for the termix-identity
* plugin.
*
* Core encrypted termix_identity_ca.private_key with the owner's data key.
* The plugin adopts that table as p_termix_identity_ca and reads the key with
* ctx.secrets.unseal, which only opens installation-key encryption. Each row
* still under a data key is decrypted and written back with
* encryptSystemSecret, what ctx.secrets.seal writes.
*
* Does nothing until the plugin's table exists. A row whose owner's data key
* cannot be opened is skipped and tried again next time. Idempotent.
*/
import { sql } from "drizzle-orm";
import { databaseLogger } from "../logger.js";
import { DataCrypto } from "../data-crypto.js";
import { LazyFieldEncryption } from "../lazy-field-encryption.js";
import {
encryptSystemSecret,
isSystemEncrypted,
} from "../system-secret-crypto.js";
import { runStatement, selectRows } from "./raw-rows.js";
const CA_TABLE = "p_termix_identity_ca";
interface CaRow {
id: number;
user_id: string;
private_key: string | null;
}
export async function runTermixIdentityCaMigration(): Promise<number> {
let rows: CaRow[];
try {
rows = await selectRows<CaRow>(
sql`SELECT id, user_id, private_key FROM ${sql.identifier(CA_TABLE)}`,
);
} catch {
// The plugin has not adopted the table yet.
return 0;
}
let resealed = 0;
for (const row of rows) {
if (!row.private_key || isSystemEncrypted(row.private_key)) continue;
try {
const dek = DataCrypto.getUserDataKey(row.user_id);
if (!dek) continue;
const plain = LazyFieldEncryption.safeGetFieldValue(
row.private_key,
dek,
String(row.id),
"privateKey",
);
if (!plain) continue;
await runStatement(
sql`UPDATE ${sql.identifier(CA_TABLE)} SET private_key = ${await encryptSystemSecret(plain)} WHERE id = ${row.id}`,
);
resealed++;
} catch (error) {
databaseLogger.warn("Termix ID CA reseal failed for a row", {
operation: "termix_identity_ca_migration",
caId: row.id,
error: error instanceof Error ? error.message : String(error),
});
}
}
if (resealed > 0) {
databaseLogger.info("Resealed Termix ID CA keys for the plugin", {
operation: "termix_identity_ca_migration",
resealed,
});
}
return resealed;
}
-1
View File
@@ -39,7 +39,6 @@ class FieldCrypto {
// Channel configs hold ntfy tokens, webhook auth headers and Discord
// webhook URLs, which are credentials like any other.
notification_channels: new Set(["config"]),
termix_identity_ca: new Set(["privateKey"]),
};
static encryptField(