mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35:24 +00:00
feat: move termix identity into a plugin (C6)
This commit is contained in:
1 parent
6e56c90aac
commit
4dbce08379
157 files changed
+20743
-5766
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user