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
Reference in new issue
Block a user