mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-11-07 02:55:24 +00:00
feat: let plugins store a CA-signed certificate on a credential (#1764)
A credential that carries an SSH key can already hold a CA-signed certificate, but the only way to fill it in is the credentials editor. A plugin that issues certificates has no way to keep the credential in step, so after a renewal it keeps presenting the previous certificate and the connection fails much later with a generic authentication error. ctx.credentials.setSshKeyCertificate stores one on a key credential the acting user owns, or clears it with null. It needs credentials:write and the core credentials.update permission, refuses anything that is not a key credential because core never sends a certificate for the other auth types, and is audited like the other ctx.credentials writes. Co-authored-by: Stefan Wienert <stefan@piratopedia.de> Co-authored-by: LukeGus <bugattiguy527@gmail.com>
This commit is contained in:
7 files changed
+258
No files matched your search
@@ -12,6 +12,7 @@
|
||||
- A plugin a newer release ships is downloaded on its own when your image does not have it, and tried again on the next start when offline
|
||||
- Plugins can decide who may connect to a host, so a host can need approved access before an SSH or remote desktop session opens
|
||||
- When more than one plugin flags a host, its row shows one status dot with every reason in its tooltip
|
||||
- Plugins that issue SSH certificates can save them on the SSH key login they were issued for
|
||||
|
||||
### Changed
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ Plugin API 1.15.0.
|
||||
|
||||
### Added
|
||||
|
||||
- `ctx.credentials.setSshKeyCertificate` stores an OpenSSH certificate on one of the acting user's SSH key credentials, or clears it with `null`
|
||||
- `/ui` exports `ToolbarBand` (a toolbar row outside the PanelShell toolbar), `ToolbarSelect` (a small select for those rows) and `TOOLBAR_FIT` (the classes that make every control in a row 32px)
|
||||
- `PANEL.section`, the one inner layout for a SectionCard body
|
||||
- `TextAreaField` takes `fill`, so a config or log editor uses the rest of the height
|
||||
|
||||
@@ -354,6 +354,10 @@ router.post("/notes", async (req, res) => {
|
||||
`allowManualCreate: () => false` blocks adding hosts by hand. Your own `ctx.hosts` writes still go
|
||||
through. `ctx.hosts.setShares(hostId, grants)` replaces a host's whole grant list, and
|
||||
`ctx.credentials.list()` finds the acting user's credentials by name.
|
||||
- `ctx.credentials.setSshKeyCertificate(credentialId, certificate)` stores a CA-signed OpenSSH
|
||||
certificate (a `-cert.pub` line, or `null` to clear it) on one of the acting user's SSH key
|
||||
credentials. Core then offers key plus certificate on every connection through that credential, so a
|
||||
plugin that issues certificates can keep the credential current instead of asking the user to paste it.
|
||||
- Connect policy: with `hosts:connect-policy`, `ctx.hosts.connectPolicy.provide({ check(input) })` is asked
|
||||
before every SSH config is built and every remote desktop login is handed out (`input` has `userId`,
|
||||
`hostId`, `protocol`, `purpose`, `profile`, `interactive`, `origin`). Return `{ decision: "allow" }`,
|
||||
|
||||
@@ -2789,6 +2789,18 @@ export interface PluginCredentials {
|
||||
key: string,
|
||||
passphrase?: string | null,
|
||||
) => Promise<{ key: string; passphrase?: string }>;
|
||||
/**
|
||||
* Stores a CA-signed OpenSSH certificate next to an existing SSH key
|
||||
* credential, so core offers key plus certificate when it connects through
|
||||
* that credential. Only key credentials can carry one; core refuses the
|
||||
* other auth types instead of dropping the certificate silently. Pass null
|
||||
* to clear a stored one. Needs credentials:write and the core
|
||||
* credentials.update permission. Audited.
|
||||
*/
|
||||
setSshKeyCertificate: (
|
||||
credentialId: number,
|
||||
certificate: string | null,
|
||||
) => Promise<{ id: number; changed: boolean }>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -355,6 +355,11 @@ export interface FakePluginContext {
|
||||
credentialReads: Array<{ hostId: number; protocol: string }>;
|
||||
/** Every ctx.credentials.createSshKey call, with the id it answered. */
|
||||
createdSshKeys: Array<PluginSshKeyCredentialInput & { id: number }>;
|
||||
/** Every ctx.credentials.setSshKeyCertificate call, in order. */
|
||||
certificateWrites: Array<{
|
||||
credentialId: number;
|
||||
certificate: string | null;
|
||||
}>;
|
||||
/** Every ctx.notify.send call, with the actor it ran as. */
|
||||
notifications: Array<{
|
||||
actor: string | undefined;
|
||||
@@ -576,6 +581,7 @@ export function createFakeContext(
|
||||
[];
|
||||
const credentialReads: FakePluginContext["credentialReads"] = [];
|
||||
const createdSshKeys: FakePluginContext["createdSshKeys"] = [];
|
||||
const certificateWrites: FakePluginContext["certificateWrites"] = [];
|
||||
const notifications: FakePluginContext["notifications"] = [];
|
||||
const notifyHub: FakePluginContext["notifyHub"] = { current: null };
|
||||
const operationHandlers: FakePluginContext["operationHandlers"] = new Map();
|
||||
@@ -1402,6 +1408,10 @@ export function createFakeContext(
|
||||
publicKey: async (credentialId) =>
|
||||
(options.sshKeyCredentials ?? []).find((k) => k.id === credentialId)
|
||||
?.publicKey ?? null,
|
||||
setSshKeyCertificate: async (credentialId, certificate) => {
|
||||
certificateWrites.push({ credentialId, certificate });
|
||||
return { id: credentialId, changed: true };
|
||||
},
|
||||
resolveKeyRefs: async (key, passphrase) => ({
|
||||
key,
|
||||
passphrase: passphrase ?? undefined,
|
||||
@@ -1616,6 +1626,7 @@ export function createFakeContext(
|
||||
externalClientLaunches,
|
||||
credentialReads,
|
||||
createdSshKeys,
|
||||
certificateWrites,
|
||||
notifications,
|
||||
notifyHub,
|
||||
operationHandlers,
|
||||
@@ -2307,6 +2318,10 @@ export function createMockCtx(
|
||||
require("credentials:use");
|
||||
return ctx.credentials.publicKey(credentialId);
|
||||
},
|
||||
setSshKeyCertificate: async (credentialId, certificate) => {
|
||||
require("credentials:write");
|
||||
return ctx.credentials.setSshKeyCertificate(credentialId, certificate);
|
||||
},
|
||||
resolveKeyRefs: async (key, passphrase) => {
|
||||
require("credentials:read");
|
||||
return ctx.credentials.resolveKeyRefs(key, passphrase);
|
||||
|
||||
@@ -85,6 +85,33 @@ function errorText(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
/**
|
||||
* A certificate is read by sshd, not by a shell, so keep it to one line of a
|
||||
* type sshd knows. A pasted private key, a PEM block or a second line would
|
||||
* otherwise be stored happily and fail much later as an opaque authentication
|
||||
* error on connect.
|
||||
*/
|
||||
function certificateValue(certificate: unknown): string | null {
|
||||
if (certificate === null || certificate === undefined) return null;
|
||||
if (typeof certificate !== "string") {
|
||||
throw new Error("A certificate must be a string or null");
|
||||
}
|
||||
const value = certificate.trim();
|
||||
if (!value) return null;
|
||||
if (value.includes("\n")) {
|
||||
throw new Error("A certificate must be a single line");
|
||||
}
|
||||
const type = value.split(/\s+/)[0];
|
||||
if (
|
||||
!/^(ssh-(ed25519|rsa)|ecdsa-sha2-nistp(256|384|521))-cert-v01@openssh\.com$/.test(
|
||||
type,
|
||||
)
|
||||
) {
|
||||
throw new Error("That is not an OpenSSH user certificate");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function requireCorePermission(
|
||||
userId: string,
|
||||
permission: string,
|
||||
@@ -225,6 +252,45 @@ export function createPluginCredentials({
|
||||
}
|
||||
},
|
||||
|
||||
setSshKeyCertificate: async (credentialId, certificate) => {
|
||||
const details = `certificate of credential ${credentialId}`;
|
||||
try {
|
||||
await assertCapability(pluginId, "credentials:write", declared);
|
||||
const userId = actingUser();
|
||||
await requireCorePermission(userId, "credentials.update");
|
||||
if (!Number.isInteger(credentialId) || credentialId <= 0) {
|
||||
throw new Error("A credential id is required");
|
||||
}
|
||||
const value = certificateValue(certificate);
|
||||
const { createCurrentCredentialRepository } =
|
||||
await import("../database/repositories/factory.js");
|
||||
const repository = createCurrentCredentialRepository();
|
||||
const credential = await repository.findByIdForUser(
|
||||
userId,
|
||||
credentialId,
|
||||
);
|
||||
if (!credential) {
|
||||
throw new Error("Credential not found");
|
||||
}
|
||||
// Core only sends a certificate for key auth; storing one on a password
|
||||
// or agent credential would look saved and never be used.
|
||||
if (credential.authType !== "key") {
|
||||
throw new Error("Only SSH key credentials can carry a certificate");
|
||||
}
|
||||
const updated = await repository.updateForUser(userId, credentialId, {
|
||||
certPublicKey: value,
|
||||
});
|
||||
await audit("credentials_set_certificate", details, { success: true });
|
||||
return { id: credentialId, changed: Boolean(updated) };
|
||||
} catch (error) {
|
||||
await audit("credentials_set_certificate", details, {
|
||||
success: false,
|
||||
errorMessage: errorText(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
publicKey: async (credentialId) => {
|
||||
const details = `public key of credential ${credentialId}`;
|
||||
try {
|
||||
|
||||
@@ -17,6 +17,7 @@ const state = vi.hoisted(() => ({
|
||||
resolverCalls: [] as Array<Record<string, unknown>>,
|
||||
savedCredentials: [] as Array<Record<string, unknown>>,
|
||||
userCredentials: new Map<string, Array<Record<string, unknown>>>(),
|
||||
credentialUpdates: [] as Array<Record<string, unknown>>,
|
||||
corePermissions: new Set<string>(["credentials.view", "credentials.create"]),
|
||||
logins: new Map<string, Record<string, unknown>>(),
|
||||
ownerDecrypts: 0,
|
||||
@@ -33,6 +34,23 @@ vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentCredentialRepository: () => ({
|
||||
listDecryptedByUserId: async (userId: string) =>
|
||||
state.userCredentials.get(userId) ?? [],
|
||||
findByIdForUser: async (userId: string, credentialId: number) =>
|
||||
(state.userCredentials.get(userId) ?? []).find(
|
||||
(row) => row.id === credentialId,
|
||||
) ?? null,
|
||||
updateForUser: async (
|
||||
userId: string,
|
||||
credentialId: number,
|
||||
update: Record<string, unknown>,
|
||||
) => {
|
||||
const row = (state.userCredentials.get(userId) ?? []).find(
|
||||
(entry) => entry.id === credentialId,
|
||||
);
|
||||
if (!row) return null;
|
||||
Object.assign(row, update);
|
||||
state.credentialUpdates.push({ userId, credentialId, update });
|
||||
return row;
|
||||
},
|
||||
createEncryptedForUser: async (
|
||||
userId: string,
|
||||
data: Record<string, unknown>,
|
||||
@@ -177,6 +195,7 @@ beforeEach(() => {
|
||||
state.resolverCalls.length = 0;
|
||||
state.savedCredentials.length = 0;
|
||||
state.userCredentials.clear();
|
||||
state.credentialUpdates.length = 0;
|
||||
state.corePermissions = new Set(["credentials.view", "credentials.create"]);
|
||||
state.hosts.set(7, { ...baseHost });
|
||||
state.logins.clear();
|
||||
@@ -641,3 +660,143 @@ describe("ctx.credentials.createSshKey", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.credentials.setSshKeyCertificate", () => {
|
||||
const certificate =
|
||||
"ssh-ed25519-cert-v01@openssh.com AAAAC3NzaC1lZDI1NTE5AAAAIBtest termix:@owner:7";
|
||||
|
||||
beforeEach(() => {
|
||||
state.corePermissions.add("credentials.update");
|
||||
state.userCredentials.set("owner", [
|
||||
{
|
||||
id: 7,
|
||||
userId: "owner",
|
||||
authType: "key",
|
||||
name: "Laptop",
|
||||
certPublicKey: null,
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
userId: "owner",
|
||||
authType: "password",
|
||||
name: "Legacy",
|
||||
certPublicKey: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("refuses without credentials:write and audits the refusal", async () => {
|
||||
grants.set("demo", ["credentials:use"]);
|
||||
const ctx = contextFor(["credentials:use"]);
|
||||
|
||||
await expect(
|
||||
runAsActor("owner", "request", () =>
|
||||
ctx.credentials.setSshKeyCertificate(7, certificate),
|
||||
),
|
||||
).rejects.toThrow(/credentials:write/);
|
||||
expect(state.credentialUpdates).toHaveLength(0);
|
||||
expect(auditEntries.at(-1)).toMatchObject({
|
||||
action: "plugin_credentials_set_certificate",
|
||||
success: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses without the core credentials.update permission", async () => {
|
||||
grants.set("demo", ["credentials:write"]);
|
||||
state.corePermissions.delete("credentials.update");
|
||||
const ctx = contextFor(["credentials:write"]);
|
||||
|
||||
await expect(
|
||||
runAsActor("owner", "request", () =>
|
||||
ctx.credentials.setSshKeyCertificate(7, certificate),
|
||||
),
|
||||
).rejects.toThrow(/credentials.update/);
|
||||
expect(state.credentialUpdates).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("stores the certificate on the actor's key credential", async () => {
|
||||
grants.set("demo", ["credentials:write"]);
|
||||
const ctx = contextFor(["credentials:write"]);
|
||||
|
||||
const result = await runAsActor("owner", "request", () =>
|
||||
ctx.credentials.setSshKeyCertificate(7, certificate),
|
||||
);
|
||||
|
||||
expect(result).toEqual({ id: 7, changed: true });
|
||||
expect(state.credentialUpdates[0]).toMatchObject({
|
||||
userId: "owner",
|
||||
credentialId: 7,
|
||||
update: { certPublicKey: certificate },
|
||||
});
|
||||
expect(state.userCredentials.get("owner")?.[0].certPublicKey).toBe(
|
||||
certificate,
|
||||
);
|
||||
expect(auditEntries.at(-1)).toMatchObject({
|
||||
action: "plugin_credentials_set_certificate",
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("clears a stored certificate with null", async () => {
|
||||
grants.set("demo", ["credentials:write"]);
|
||||
state.userCredentials.get("owner")![0].certPublicKey = certificate;
|
||||
const ctx = contextFor(["credentials:write"]);
|
||||
|
||||
const result = await runAsActor("owner", "request", () =>
|
||||
ctx.credentials.setSshKeyCertificate(7, null),
|
||||
);
|
||||
|
||||
expect(result).toEqual({ id: 7, changed: true });
|
||||
expect(state.userCredentials.get("owner")?.[0].certPublicKey).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses a credential core never sends a certificate for", async () => {
|
||||
grants.set("demo", ["credentials:write"]);
|
||||
const ctx = contextFor(["credentials:write"]);
|
||||
|
||||
await expect(
|
||||
runAsActor("owner", "request", () =>
|
||||
ctx.credentials.setSshKeyCertificate(8, certificate),
|
||||
),
|
||||
).rejects.toThrow(/Only SSH key credentials/);
|
||||
expect(state.credentialUpdates).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("refuses a credential the actor does not own", async () => {
|
||||
grants.set("demo", ["credentials:write"]);
|
||||
const ctx = contextFor(["credentials:write"]);
|
||||
|
||||
await expect(
|
||||
runAsActor("owner", "request", () =>
|
||||
ctx.credentials.setSshKeyCertificate(99, certificate),
|
||||
),
|
||||
).rejects.toThrow(/Credential not found/);
|
||||
});
|
||||
|
||||
it("refuses a line that is not an OpenSSH user certificate", async () => {
|
||||
grants.set("demo", ["credentials:write"]);
|
||||
const ctx = contextFor(["credentials:write"]);
|
||||
|
||||
await expect(
|
||||
runAsActor("owner", "request", () =>
|
||||
ctx.credentials.setSshKeyCertificate(7, "ssh-ed25519 AAAAC3Nza"),
|
||||
),
|
||||
).rejects.toThrow(/not an OpenSSH user certificate/);
|
||||
expect(state.credentialUpdates).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("refuses a multi-line paste", async () => {
|
||||
grants.set("demo", ["credentials:write"]);
|
||||
const ctx = contextFor(["credentials:write"]);
|
||||
|
||||
await expect(
|
||||
runAsActor("owner", "request", () =>
|
||||
ctx.credentials.setSshKeyCertificate(
|
||||
7,
|
||||
`${certificate}\n${certificate}`,
|
||||
),
|
||||
),
|
||||
).rejects.toThrow(/single line/);
|
||||
expect(state.credentialUpdates).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user