mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-11-07 02:55:24 +00:00
v26.10.3 (#1692)
* feat: shell controller, TabStage, layout and theme plugins with repair view * feat: move the classic layout into its own bundled plugin * feat: move color themes into a bundled themes plugin * fix: plugin tests and type checks work with a linked sdk * fix: home tab, layout switch and missing layout fallbacks for looks * chore: add classic layout and themes to the readme * chore: drop example plugin links from the plugin guide * feat: plugin filters by type, paged browse, hide plugins needing newer termix * feat: move desktop, status, import, audit and branding features into plugins * feat: host lock api so a plugin can load hosts from a config file (#1581) * chore: add hosts config to the plugin table * feat: login policy hook for plugins and optional https redirect (#1584) * chore: add login security to the plugin table * feat: api key socket tickets so a master can open remote sessions (#1654) * feat: sync moves to a plugin, desktop gets multiple accounts (#1580, #1654) * chore: add sync to the plugin table * feat: move the sync screens and wizard out of core into the plugin (#1580) * fix: add a neutral linked desktop hook for plugins (#1580) * fix: show the linked account's name and admin flag in the app (#1580) * feat: ssh transport providers, core ssh relay and plugin folders api * feat: support FIDO2 security keys through ssh agent * feat: add companion panels beside host tabs * feat: mTLS client certificates for desktop server links * feat: native web view for plugins in the desktop app * fix: detect the desktop link window by Electron's default user agent Fixes #1675 * fix: changelog for desktop link fix * fix: keep text primary keys in the plugin data export * fix: ship sharp binaries for Windows arm64 builds * feat: client cert on the sync link types (sdk 1.0.18) * chore: update translations * feat: themed title bar for the desktop app (#1690) * chore: changelog for the themed title bar * fix: draw the title bar in the sidebar color * feat: per host layout for the terminal panel bar * chore: sdk 1.0.19 for the panel bar layout * chore: bundle all 43 plugins for 26.10.3 * chore: update translations --------- Co-authored-by: SirHephaistos <60616553+SirHephaistos@users.noreply.github.com> Co-authored-by: Giovanny Aranda <garanda21@gmail.com>
This commit is contained in:
577 files changed
+76479
-101831
No files matched your search
@@ -1,125 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
escapeCsvField,
|
||||
exportFilename,
|
||||
toCsv,
|
||||
toNdjson,
|
||||
} from "../../utils/audit-export.js";
|
||||
import type { AuditLogRecord } from "../../database/repositories/audit-log-repository.js";
|
||||
|
||||
function entry(overrides: Partial<AuditLogRecord> = {}): AuditLogRecord {
|
||||
return {
|
||||
id: 1,
|
||||
userId: "u-1",
|
||||
username: "alice",
|
||||
action: "delete_host",
|
||||
resourceType: "host",
|
||||
resourceId: "9",
|
||||
resourceName: "prod-db",
|
||||
details: null,
|
||||
ipAddress: "203.0.113.9",
|
||||
userAgent: "Mozilla/5.0",
|
||||
success: true,
|
||||
errorMessage: null,
|
||||
timestamp: "2026-07-28 10:00:00",
|
||||
...overrides,
|
||||
} as AuditLogRecord;
|
||||
}
|
||||
|
||||
describe("escapeCsvField", () => {
|
||||
it("leaves plain values alone", () => {
|
||||
expect(escapeCsvField("prod-db")).toBe("prod-db");
|
||||
expect(escapeCsvField(42)).toBe("42");
|
||||
expect(escapeCsvField(true)).toBe("true");
|
||||
});
|
||||
|
||||
it("renders null and undefined as empty", () => {
|
||||
expect(escapeCsvField(null)).toBe("");
|
||||
expect(escapeCsvField(undefined)).toBe("");
|
||||
});
|
||||
|
||||
it("quotes and doubles embedded quotes", () => {
|
||||
expect(escapeCsvField('say "hi"')).toBe('"say ""hi"""');
|
||||
});
|
||||
|
||||
it("quotes values containing commas or newlines", () => {
|
||||
expect(escapeCsvField("a,b")).toBe('"a,b"');
|
||||
expect(escapeCsvField("line1\nline2")).toBe('"line1\nline2"');
|
||||
});
|
||||
|
||||
it("neutralises spreadsheet formulas", () => {
|
||||
// An audit entry can carry an attacker-chosen resource name; without this
|
||||
// the exported file executes it when opened.
|
||||
expect(escapeCsvField("=1+1")).toBe("'=1+1");
|
||||
expect(escapeCsvField("+SUM(A1)")).toBe("'+SUM(A1)");
|
||||
expect(escapeCsvField("-2+3")).toBe("'-2+3");
|
||||
expect(escapeCsvField("@import")).toBe("'@import");
|
||||
});
|
||||
|
||||
it("still quotes a formula that also contains a comma", () => {
|
||||
expect(escapeCsvField("=A1,B2")).toBe(`"'=A1,B2"`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toCsv", () => {
|
||||
it("writes a header even with no rows", () => {
|
||||
expect(toCsv([])).toBe(
|
||||
"id,timestamp,username,userId,action,resourceType,resourceId,resourceName,success,ipAddress,userAgent,errorMessage,details\n",
|
||||
);
|
||||
});
|
||||
|
||||
it("writes one line per entry in column order", () => {
|
||||
const lines = toCsv([entry(), entry({ id: 2, username: "bob" })])
|
||||
.trim()
|
||||
.split("\n");
|
||||
|
||||
expect(lines).toHaveLength(3);
|
||||
expect(
|
||||
lines[1].startsWith("1,2026-07-28 10:00:00,alice,u-1,delete_host"),
|
||||
).toBe(true);
|
||||
expect(lines[2].startsWith("2,")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps a detached entry readable", () => {
|
||||
const line = toCsv([entry({ userId: null })])
|
||||
.trim()
|
||||
.split("\n")[1];
|
||||
|
||||
// username survives so the row still names who acted.
|
||||
expect(line).toContain("alice");
|
||||
expect(line.split(",")[3]).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("toNdjson", () => {
|
||||
it("emits one parseable object per line", () => {
|
||||
const out = toNdjson([entry(), entry({ id: 2 })]);
|
||||
const parsed = out
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => JSON.parse(line));
|
||||
|
||||
expect(parsed).toHaveLength(2);
|
||||
expect(parsed[0].action).toBe("delete_host");
|
||||
expect(parsed[1].id).toBe(2);
|
||||
});
|
||||
|
||||
it("returns nothing for an empty set", () => {
|
||||
expect(toNdjson([])).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("exportFilename", () => {
|
||||
it("is filesystem-safe and carries the timestamp", () => {
|
||||
const name = exportFilename("csv", new Date("2026-07-28T10:11:12.000Z"));
|
||||
|
||||
expect(name).toBe("termix-audit-2026-07-28-10-11-12.csv");
|
||||
expect(name).not.toMatch(/[:\s]/);
|
||||
});
|
||||
|
||||
it("uses the ndjson extension for the streaming format", () => {
|
||||
expect(exportFilename("ndjson", new Date("2026-07-28T10:11:12.000Z"))).toBe(
|
||||
"termix-audit-2026-07-28-10-11-12.ndjson",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,169 +0,0 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
const safeFetch = vi.hoisted(() => vi.fn());
|
||||
const logs = vi.hoisted(() => ({ info: vi.fn(), warn: vi.fn() }));
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
getCurrentSettingValue: () => null,
|
||||
}));
|
||||
vi.mock("../../utils/safe-outbound-fetch.js", () => ({
|
||||
safeOutboundFetch: safeFetch,
|
||||
}));
|
||||
vi.mock("../../utils/logger.js", () => ({ databaseLogger: logs }));
|
||||
|
||||
const {
|
||||
auditForwardTarget,
|
||||
forwardAuditEntry,
|
||||
forwardPayload,
|
||||
resetAuditForwarderState,
|
||||
AUDIT_FORWARD_URL_ENV,
|
||||
AUDIT_FORWARD_TOKEN_ENV,
|
||||
} = await import("../../utils/audit-forwarder.js");
|
||||
|
||||
const ENTRY = {
|
||||
userId: "u-1",
|
||||
username: "alice",
|
||||
action: "delete_host",
|
||||
resourceType: "host",
|
||||
resourceId: "9",
|
||||
success: true,
|
||||
ipAddress: "203.0.113.9",
|
||||
};
|
||||
|
||||
const NOW = new Date("2026-07-28T10:00:00.000Z");
|
||||
|
||||
beforeEach(() => {
|
||||
safeFetch.mockReset();
|
||||
logs.info.mockReset();
|
||||
logs.warn.mockReset();
|
||||
resetAuditForwarderState();
|
||||
});
|
||||
|
||||
describe("auditForwardTarget", () => {
|
||||
it("is off unless a URL is configured", () => {
|
||||
expect(auditForwardTarget({})).toBeNull();
|
||||
expect(auditForwardTarget({ [AUDIT_FORWARD_URL_ENV]: " " })).toBeNull();
|
||||
});
|
||||
|
||||
it("carries an optional bearer token", () => {
|
||||
expect(
|
||||
auditForwardTarget({ [AUDIT_FORWARD_URL_ENV]: "https://siem/ingest" }),
|
||||
).toEqual({ url: "https://siem/ingest" });
|
||||
|
||||
expect(
|
||||
auditForwardTarget({
|
||||
[AUDIT_FORWARD_URL_ENV]: "https://siem/ingest",
|
||||
[AUDIT_FORWARD_TOKEN_ENV]: "secret",
|
||||
}),
|
||||
).toEqual({ url: "https://siem/ingest", token: "secret" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("forwardPayload", () => {
|
||||
it("matches the export shape, with absent fields as null", () => {
|
||||
expect(forwardPayload(ENTRY, NOW)).toEqual({
|
||||
timestamp: "2026-07-28T10:00:00.000Z",
|
||||
userId: "u-1",
|
||||
username: "alice",
|
||||
action: "delete_host",
|
||||
resourceType: "host",
|
||||
resourceId: "9",
|
||||
resourceName: null,
|
||||
success: true,
|
||||
ipAddress: "203.0.113.9",
|
||||
userAgent: null,
|
||||
errorMessage: null,
|
||||
details: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("forwardAuditEntry", () => {
|
||||
const env = { [AUDIT_FORWARD_URL_ENV]: "https://siem.example/ingest" };
|
||||
|
||||
it("does nothing when forwarding is not configured", async () => {
|
||||
await expect(forwardAuditEntry(ENTRY, NOW, {})).resolves.toBe(false);
|
||||
expect(safeFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("posts one NDJSON line through the SSRF-checked fetch", async () => {
|
||||
safeFetch.mockResolvedValueOnce({ ok: true, status: 200 });
|
||||
|
||||
await expect(forwardAuditEntry(ENTRY, NOW, env)).resolves.toBe(true);
|
||||
|
||||
const [url, init] = safeFetch.mock.calls[0];
|
||||
expect(url).toBe("https://siem.example/ingest");
|
||||
expect(init.method).toBe("POST");
|
||||
expect(init.headers["Content-Type"]).toBe("application/x-ndjson");
|
||||
expect(init.headers.Authorization).toBeUndefined();
|
||||
expect(JSON.parse(init.body.trim()).action).toBe("delete_host");
|
||||
expect(init.body.endsWith("\n")).toBe(true);
|
||||
});
|
||||
|
||||
it("sends the bearer token when one is set", async () => {
|
||||
safeFetch.mockResolvedValueOnce({ ok: true, status: 200 });
|
||||
|
||||
await forwardAuditEntry(ENTRY, NOW, {
|
||||
...env,
|
||||
[AUDIT_FORWARD_TOKEN_ENV]: "secret",
|
||||
});
|
||||
|
||||
expect(safeFetch.mock.calls[0][1].headers.Authorization).toBe(
|
||||
"Bearer secret",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a rejected delivery without throwing", async () => {
|
||||
safeFetch.mockResolvedValueOnce({ ok: false, status: 503 });
|
||||
|
||||
await expect(forwardAuditEntry(ENTRY, NOW, env)).resolves.toBe(false);
|
||||
expect(logs.warn).toHaveBeenCalledWith(
|
||||
"Failed to forward audit entry",
|
||||
expect.objectContaining({ reason: "collector returned 503" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("swallows transport errors, a dead SIEM must not break auditing", async () => {
|
||||
safeFetch.mockRejectedValueOnce(new Error("ECONNREFUSED"));
|
||||
|
||||
await expect(forwardAuditEntry(ENTRY, NOW, env)).resolves.toBe(false);
|
||||
expect(logs.warn).toHaveBeenCalledWith(
|
||||
"Failed to forward audit entry",
|
||||
expect.objectContaining({ reason: "ECONNREFUSED" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("stops repeating itself once the collector is persistently down", async () => {
|
||||
safeFetch.mockResolvedValue({ ok: false, status: 500 });
|
||||
|
||||
for (let i = 0; i < 8; i++) {
|
||||
await forwardAuditEntry(ENTRY, NOW, env);
|
||||
}
|
||||
|
||||
// 5 per-entry warnings, then one suppression notice, not 8.
|
||||
const perEntry = logs.warn.mock.calls.filter(
|
||||
(call) => call[0] === "Failed to forward audit entry",
|
||||
);
|
||||
expect(perEntry).toHaveLength(5);
|
||||
expect(
|
||||
logs.warn.mock.calls.some((call) =>
|
||||
String(call[0]).includes("suppressing further messages"),
|
||||
),
|
||||
).toBe(true);
|
||||
// It keeps trying regardless.
|
||||
expect(safeFetch).toHaveBeenCalledTimes(8);
|
||||
});
|
||||
|
||||
it("announces recovery after a suppressed outage", async () => {
|
||||
safeFetch.mockResolvedValue({ ok: false, status: 500 });
|
||||
for (let i = 0; i < 6; i++) await forwardAuditEntry(ENTRY, NOW, env);
|
||||
|
||||
safeFetch.mockResolvedValueOnce({ ok: true, status: 200 });
|
||||
await forwardAuditEntry(ENTRY, NOW, env);
|
||||
|
||||
expect(logs.info).toHaveBeenCalledWith(
|
||||
"Audit forwarding recovered",
|
||||
expect.objectContaining({ operation: "audit_forward_recovered" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,8 @@ const encryptionKey = crypto.randomBytes(32);
|
||||
const mocks = vi.hoisted(() => ({
|
||||
hasUserDEK: vi.fn(() => false),
|
||||
adoptRecoveredDEK: vi.fn(async () => {}),
|
||||
revoke: vi.fn(async () => true),
|
||||
created: [] as Array<Record<string, unknown>>,
|
||||
}));
|
||||
|
||||
vi.mock("../../database/db/index.js", () => ({
|
||||
@@ -19,10 +21,27 @@ vi.mock("../../database/db/index.js", () => ({
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentSettingsRepository: () => ({ get: async () => null }),
|
||||
createCurrentSessionRepository: () => ({
|
||||
findById: async (id: string) =>
|
||||
id === "expired"
|
||||
? { id, expiresAt: new Date(Date.now() - 1000).toISOString() }
|
||||
: { id },
|
||||
findById: async (id: string) => {
|
||||
const minutesAgo = (n: number) =>
|
||||
new Date(Date.now() - n * 60_000).toISOString();
|
||||
if (id === "expired") {
|
||||
return { id, expiresAt: new Date(Date.now() - 1000).toISOString() };
|
||||
}
|
||||
if (id === "idle") {
|
||||
return { id, idleTimeout: 600, lastActiveAt: minutesAgo(11) };
|
||||
}
|
||||
if (id === "active") {
|
||||
return { id, idleTimeout: 600, lastActiveAt: minutesAgo(5) };
|
||||
}
|
||||
if (id === "setup") return { id, restriction: "second-factor-setup" };
|
||||
return { id };
|
||||
},
|
||||
create: async (row: Record<string, unknown>) => {
|
||||
mocks.created.push(row);
|
||||
return row;
|
||||
},
|
||||
revoke: mocks.revoke,
|
||||
touch: async () => true,
|
||||
}),
|
||||
createCurrentUserRepository: () => ({}),
|
||||
createCurrentApiKeyRepository: () => ({}),
|
||||
@@ -54,7 +73,9 @@ vi.mock("../../utils/system-crypto.js", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const { AuthManager } = await import("../../utils/auth-manager.js");
|
||||
const { AuthManager, allowsSetupSession } =
|
||||
await import("../../utils/auth-manager.js");
|
||||
const { registerSecondFactor } = await import("../../auth/registry.js");
|
||||
const authManager = AuthManager.getInstance();
|
||||
|
||||
function makeLegacyDataKeyWrap(
|
||||
@@ -74,6 +95,8 @@ function makeLegacyDataKeyWrap(
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.created.length = 0;
|
||||
mocks.revoke.mockClear();
|
||||
mocks.hasUserDEK.mockReset().mockReturnValue(false);
|
||||
mocks.adoptRecoveredDEK.mockReset().mockResolvedValue(undefined);
|
||||
});
|
||||
@@ -191,6 +214,30 @@ describe("AuthManager token handling", () => {
|
||||
expect(payload?.userId).toBe("user-1");
|
||||
});
|
||||
|
||||
it("gives an API key a ticket on a short session that only a socket takes", async () => {
|
||||
mocks.created.length = 0;
|
||||
const ticket = await authManager.issueApiKeySocketTicket(
|
||||
"user-1",
|
||||
"key-12345678",
|
||||
);
|
||||
const payload = jwt.decode(ticket) as Record<string, number | string>;
|
||||
|
||||
expect(payload.userId).toBe("user-1");
|
||||
expect(payload.purpose).toBe("ws");
|
||||
expect(Number(payload.exp) - Number(payload.iat)).toBe(60);
|
||||
|
||||
const [session] = mocks.created;
|
||||
expect(session.userId).toBe("user-1");
|
||||
expect(session.id).toBe(payload.sessionId);
|
||||
expect(session.deviceInfo).toBe("api-key: socket key-1234");
|
||||
const lifetime =
|
||||
new Date(String(session.expiresAt)).getTime() -
|
||||
new Date(String(session.createdAt)).getTime();
|
||||
expect(lifetime).toBe(120_000);
|
||||
|
||||
expect(await authManager.verifyJWTToken(ticket)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects a socket ticket whose session has expired", async () => {
|
||||
const ticket = await authManager.issueSocketTicket("user-1", "expired");
|
||||
expect(
|
||||
@@ -209,3 +256,97 @@ describe("AuthManager token handling", () => {
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("login policy on sessions", () => {
|
||||
const sign = (sessionId: string) =>
|
||||
jwt.sign({ userId: "user-1", sessionId }, jwtSecret, { expiresIn: "1h" });
|
||||
|
||||
it("caps the lifetime and stores the idle timeout and restriction", async () => {
|
||||
const token = await authManager.generateJWTToken("user-1", {
|
||||
rememberMe: true,
|
||||
deviceType: "web",
|
||||
deviceInfo: "Firefox",
|
||||
maxAgeSeconds: 600,
|
||||
idleTimeoutSeconds: 300,
|
||||
restriction: "second-factor-setup",
|
||||
});
|
||||
const payload = jwt.decode(token) as { iat: number; exp: number };
|
||||
expect(payload.exp - payload.iat).toBe(600);
|
||||
expect(mocks.created[0]).toMatchObject({
|
||||
idleTimeout: 300,
|
||||
restriction: "second-factor-setup",
|
||||
});
|
||||
const lifetime =
|
||||
new Date(mocks.created[0].expiresAt as string).getTime() -
|
||||
new Date(mocks.created[0].createdAt as string).getTime();
|
||||
expect(lifetime).toBe(600_000);
|
||||
});
|
||||
|
||||
it("ends a session idle past its timeout and keeps an active one", async () => {
|
||||
expect(await authManager.verifyJWTToken(sign("idle"))).toBeNull();
|
||||
expect(mocks.revoke).toHaveBeenCalledWith("idle");
|
||||
expect(await authManager.verifyJWTToken(sign("active"))).not.toBeNull();
|
||||
});
|
||||
|
||||
it("only lets a setup session reach setup routes", async () => {
|
||||
const middleware = authManager.createAuthMiddleware();
|
||||
const call = async (url: string) => {
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
body: undefined as unknown,
|
||||
status(code: number) {
|
||||
res.statusCode = code;
|
||||
return res;
|
||||
},
|
||||
json(body: unknown) {
|
||||
res.body = body;
|
||||
return res;
|
||||
},
|
||||
clearCookie() {
|
||||
return res;
|
||||
},
|
||||
};
|
||||
const next = vi.fn();
|
||||
await middleware(
|
||||
{
|
||||
cookies: { jwt: sign("setup") },
|
||||
headers: {},
|
||||
originalUrl: url,
|
||||
url,
|
||||
} as never,
|
||||
res as never,
|
||||
next,
|
||||
);
|
||||
return { res, next };
|
||||
};
|
||||
|
||||
const blocked = await call("/host/db/host");
|
||||
expect(blocked.res.statusCode).toBe(403);
|
||||
expect(blocked.res.body).toMatchObject({
|
||||
code: "SECOND_FACTOR_SETUP_REQUIRED",
|
||||
});
|
||||
expect(blocked.next).not.toHaveBeenCalled();
|
||||
|
||||
const me = await call("/users/me");
|
||||
expect(me.next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows second factor plugins' routes and nothing else", () => {
|
||||
const dispose = registerSecondFactor({
|
||||
id: "key",
|
||||
pluginId: "keys",
|
||||
labelKey: "key",
|
||||
isEnrolled: async () => false,
|
||||
verify: async () => true,
|
||||
});
|
||||
try {
|
||||
expect(allowsSetupSession("/plugin-api/keys/register")).toBe(true);
|
||||
expect(allowsSetupSession("/plugin-api/other/thing")).toBe(false);
|
||||
expect(allowsSetupSession("/users/logout")).toBe(true);
|
||||
expect(allowsSetupSession("/users/me/token")).toBe(false);
|
||||
expect(allowsSetupSession("/credentials")).toBe(false);
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
isPrivateKeyPassphraseError,
|
||||
getFriendlyKeyTypeName,
|
||||
validateKeyPair,
|
||||
isSecurityKeyFile,
|
||||
} from "../../utils/ssh-key-utils.js";
|
||||
|
||||
const { parsePrivateKey } = sshpk;
|
||||
@@ -224,3 +225,43 @@ describe("PKCS#8 runtime key normalization", () => {
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("security key files", () => {
|
||||
const str = (v: string | Buffer) => {
|
||||
const b = Buffer.from(v);
|
||||
const l = Buffer.alloc(4);
|
||||
l.writeUInt32BE(b.length);
|
||||
return Buffer.concat([l, b]);
|
||||
};
|
||||
const skFile = (type: string) => {
|
||||
const body = Buffer.concat([
|
||||
Buffer.from("openssh-key-v1\u0000"),
|
||||
str("none"),
|
||||
str("none"),
|
||||
str(""),
|
||||
Buffer.from([0, 0, 0, 1]),
|
||||
str(Buffer.concat([str(type), str(Buffer.alloc(32, 1)), str("ssh:")])),
|
||||
str(Buffer.alloc(16)),
|
||||
]).toString("base64");
|
||||
return `-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
${body}
|
||||
-----END OPENSSH PRIVATE KEY-----`;
|
||||
};
|
||||
|
||||
it("detects sk key files", () => {
|
||||
expect(isSecurityKeyFile(skFile("sk-ssh-ed25519@openssh.com"))).toBe(true);
|
||||
expect(
|
||||
isSecurityKeyFile(skFile("sk-ecdsa-sha2-nistp256@openssh.com")),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores normal keys", () => {
|
||||
expect(isSecurityKeyFile(ED25519_PRIVATE)).toBe(false);
|
||||
});
|
||||
|
||||
it("explains how to use a security key instead of failing to decode", () => {
|
||||
expect(() =>
|
||||
preparePrivateKeyForSSH2(skFile("sk-ssh-ed25519@openssh.com")),
|
||||
).toThrow("ssh-add");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user