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
|
- 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
|
- 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
|
- 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
|
### Changed
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ Plugin API 1.15.0.
|
|||||||
|
|
||||||
### Added
|
### 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)
|
- `/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
|
- `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
|
- `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
|
`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
|
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.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
|
- 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`,
|
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" }`,
|
`hostId`, `protocol`, `purpose`, `profile`, `interactive`, `origin`). Return `{ decision: "allow" }`,
|
||||||
|
|||||||
@@ -2789,6 +2789,18 @@ export interface PluginCredentials {
|
|||||||
key: string,
|
key: string,
|
||||||
passphrase?: string | null,
|
passphrase?: string | null,
|
||||||
) => Promise<{ key: string; passphrase?: string }>;
|
) => 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 }>;
|
credentialReads: Array<{ hostId: number; protocol: string }>;
|
||||||
/** Every ctx.credentials.createSshKey call, with the id it answered. */
|
/** Every ctx.credentials.createSshKey call, with the id it answered. */
|
||||||
createdSshKeys: Array<PluginSshKeyCredentialInput & { id: number }>;
|
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. */
|
/** Every ctx.notify.send call, with the actor it ran as. */
|
||||||
notifications: Array<{
|
notifications: Array<{
|
||||||
actor: string | undefined;
|
actor: string | undefined;
|
||||||
@@ -576,6 +581,7 @@ export function createFakeContext(
|
|||||||
[];
|
[];
|
||||||
const credentialReads: FakePluginContext["credentialReads"] = [];
|
const credentialReads: FakePluginContext["credentialReads"] = [];
|
||||||
const createdSshKeys: FakePluginContext["createdSshKeys"] = [];
|
const createdSshKeys: FakePluginContext["createdSshKeys"] = [];
|
||||||
|
const certificateWrites: FakePluginContext["certificateWrites"] = [];
|
||||||
const notifications: FakePluginContext["notifications"] = [];
|
const notifications: FakePluginContext["notifications"] = [];
|
||||||
const notifyHub: FakePluginContext["notifyHub"] = { current: null };
|
const notifyHub: FakePluginContext["notifyHub"] = { current: null };
|
||||||
const operationHandlers: FakePluginContext["operationHandlers"] = new Map();
|
const operationHandlers: FakePluginContext["operationHandlers"] = new Map();
|
||||||
@@ -1402,6 +1408,10 @@ export function createFakeContext(
|
|||||||
publicKey: async (credentialId) =>
|
publicKey: async (credentialId) =>
|
||||||
(options.sshKeyCredentials ?? []).find((k) => k.id === credentialId)
|
(options.sshKeyCredentials ?? []).find((k) => k.id === credentialId)
|
||||||
?.publicKey ?? null,
|
?.publicKey ?? null,
|
||||||
|
setSshKeyCertificate: async (credentialId, certificate) => {
|
||||||
|
certificateWrites.push({ credentialId, certificate });
|
||||||
|
return { id: credentialId, changed: true };
|
||||||
|
},
|
||||||
resolveKeyRefs: async (key, passphrase) => ({
|
resolveKeyRefs: async (key, passphrase) => ({
|
||||||
key,
|
key,
|
||||||
passphrase: passphrase ?? undefined,
|
passphrase: passphrase ?? undefined,
|
||||||
@@ -1616,6 +1626,7 @@ export function createFakeContext(
|
|||||||
externalClientLaunches,
|
externalClientLaunches,
|
||||||
credentialReads,
|
credentialReads,
|
||||||
createdSshKeys,
|
createdSshKeys,
|
||||||
|
certificateWrites,
|
||||||
notifications,
|
notifications,
|
||||||
notifyHub,
|
notifyHub,
|
||||||
operationHandlers,
|
operationHandlers,
|
||||||
@@ -2307,6 +2318,10 @@ export function createMockCtx(
|
|||||||
require("credentials:use");
|
require("credentials:use");
|
||||||
return ctx.credentials.publicKey(credentialId);
|
return ctx.credentials.publicKey(credentialId);
|
||||||
},
|
},
|
||||||
|
setSshKeyCertificate: async (credentialId, certificate) => {
|
||||||
|
require("credentials:write");
|
||||||
|
return ctx.credentials.setSshKeyCertificate(credentialId, certificate);
|
||||||
|
},
|
||||||
resolveKeyRefs: async (key, passphrase) => {
|
resolveKeyRefs: async (key, passphrase) => {
|
||||||
require("credentials:read");
|
require("credentials:read");
|
||||||
return ctx.credentials.resolveKeyRefs(key, passphrase);
|
return ctx.credentials.resolveKeyRefs(key, passphrase);
|
||||||
|
|||||||
@@ -85,6 +85,33 @@ function errorText(error: unknown): string {
|
|||||||
return error instanceof Error ? error.message : String(error);
|
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(
|
async function requireCorePermission(
|
||||||
userId: string,
|
userId: string,
|
||||||
permission: 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) => {
|
publicKey: async (credentialId) => {
|
||||||
const details = `public key of credential ${credentialId}`;
|
const details = `public key of credential ${credentialId}`;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const state = vi.hoisted(() => ({
|
|||||||
resolverCalls: [] as Array<Record<string, unknown>>,
|
resolverCalls: [] as Array<Record<string, unknown>>,
|
||||||
savedCredentials: [] as Array<Record<string, unknown>>,
|
savedCredentials: [] as Array<Record<string, unknown>>,
|
||||||
userCredentials: new Map<string, 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"]),
|
corePermissions: new Set<string>(["credentials.view", "credentials.create"]),
|
||||||
logins: new Map<string, Record<string, unknown>>(),
|
logins: new Map<string, Record<string, unknown>>(),
|
||||||
ownerDecrypts: 0,
|
ownerDecrypts: 0,
|
||||||
@@ -33,6 +34,23 @@ vi.mock("../../database/repositories/factory.js", () => ({
|
|||||||
createCurrentCredentialRepository: () => ({
|
createCurrentCredentialRepository: () => ({
|
||||||
listDecryptedByUserId: async (userId: string) =>
|
listDecryptedByUserId: async (userId: string) =>
|
||||||
state.userCredentials.get(userId) ?? [],
|
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 (
|
createEncryptedForUser: async (
|
||||||
userId: string,
|
userId: string,
|
||||||
data: Record<string, unknown>,
|
data: Record<string, unknown>,
|
||||||
@@ -177,6 +195,7 @@ beforeEach(() => {
|
|||||||
state.resolverCalls.length = 0;
|
state.resolverCalls.length = 0;
|
||||||
state.savedCredentials.length = 0;
|
state.savedCredentials.length = 0;
|
||||||
state.userCredentials.clear();
|
state.userCredentials.clear();
|
||||||
|
state.credentialUpdates.length = 0;
|
||||||
state.corePermissions = new Set(["credentials.view", "credentials.create"]);
|
state.corePermissions = new Set(["credentials.view", "credentials.create"]);
|
||||||
state.hosts.set(7, { ...baseHost });
|
state.hosts.set(7, { ...baseHost });
|
||||||
state.logins.clear();
|
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