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:
authored and GitHub committed 2026-11-06 20:49:36 -06:00
1 parent b912a31e00
commit 4584aff32b
7 files changed
+258

No files matched your search

+1
View File
@@ -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
+1
View File
@@ -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
+4
View File
@@ -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" }`,
+12
View File
@@ -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 }>;
}
/**
+15
View File
@@ -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);
+66
View File
@@ -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);
});
});