From 4584aff32baff269324b347f541adf20044aec09 Mon Sep 17 00:00:00 2001 From: TheLord83 Date: Sat, 7 Nov 2026 03:49:36 +0100 Subject: [PATCH] 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 Co-authored-by: LukeGus --- CHANGELOG.md | 1 + packages/plugin-sdk/CHANGELOG.md | 1 + packages/plugin-sdk/PLUGIN-GUIDE.md | 4 + packages/plugin-sdk/src/backend.ts | 12 ++ packages/plugin-sdk/src/testing.ts | 15 ++ src/backend/plugins/ctx-credentials.ts | 66 ++++++++ .../tests/plugins/ctx-credentials.test.ts | 159 ++++++++++++++++++ 7 files changed, 258 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 235e584ee..be54cb2b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/packages/plugin-sdk/CHANGELOG.md b/packages/plugin-sdk/CHANGELOG.md index af9a7c6b1..378f496c3 100644 --- a/packages/plugin-sdk/CHANGELOG.md +++ b/packages/plugin-sdk/CHANGELOG.md @@ -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 diff --git a/packages/plugin-sdk/PLUGIN-GUIDE.md b/packages/plugin-sdk/PLUGIN-GUIDE.md index ca1c2186c..d48524feb 100644 --- a/packages/plugin-sdk/PLUGIN-GUIDE.md +++ b/packages/plugin-sdk/PLUGIN-GUIDE.md @@ -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" }`, diff --git a/packages/plugin-sdk/src/backend.ts b/packages/plugin-sdk/src/backend.ts index 88d16031f..23e2d7c6a 100644 --- a/packages/plugin-sdk/src/backend.ts +++ b/packages/plugin-sdk/src/backend.ts @@ -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 }>; } /** diff --git a/packages/plugin-sdk/src/testing.ts b/packages/plugin-sdk/src/testing.ts index 27726acb8..9ebec71d4 100644 --- a/packages/plugin-sdk/src/testing.ts +++ b/packages/plugin-sdk/src/testing.ts @@ -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; + /** 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); diff --git a/src/backend/plugins/ctx-credentials.ts b/src/backend/plugins/ctx-credentials.ts index 0f812c213..662e7adcc 100644 --- a/src/backend/plugins/ctx-credentials.ts +++ b/src/backend/plugins/ctx-credentials.ts @@ -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 { diff --git a/src/backend/tests/plugins/ctx-credentials.test.ts b/src/backend/tests/plugins/ctx-credentials.test.ts index 2a38c75bf..460b4edb0 100644 --- a/src/backend/tests/plugins/ctx-credentials.test.ts +++ b/src/backend/tests/plugins/ctx-credentials.test.ts @@ -17,6 +17,7 @@ const state = vi.hoisted(() => ({ resolverCalls: [] as Array>, savedCredentials: [] as Array>, userCredentials: new Map>>(), + credentialUpdates: [] as Array>, corePermissions: new Set(["credentials.view", "credentials.create"]), logins: new Map>(), 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, + ) => { + 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, @@ -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); + }); +});