mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-08-14 23:07:29 +00:00
Add SSH keys and config to credential sharing
Private keys have lived as plaintext-on-disk files guarded only by a passphrase. This puts them in the same end-to-end-encrypted vaults as .env secrets, and adds an agent path so a machine can use a key without ever writing one to its disk. - `ssh` provider: ~/.ssh as a value bag. Files are picked by sniffing contents (PRIVATE KEY blocks, ssh-*/ecdsa-*/sk-* public keys) plus config, config.d/* and allowed_signers. known_hosts and authorized_keys are host-specific and access-granting, so they need an explicit --include. - Each file is one secret carrying a JSON envelope of path, mode and body. The engine only hands write() the secrets that CHANGED, so a separate manifest secret would be absent whenever a key's contents change but the file list doesn't — self-describing values keep every restore total. - `logicsrc secrets ssh push|pull|list|agent`, addressed by PERSON not project: the vault is ssh--<username>, which teams vaults reads as project ssh, env <username>. One teammate's keys never land in another's restore; sharing stays a deliberate teams grant. - Both directions hold back anything that would overwrite a file that already differs, and say what they skipped. --force opts in. A restore onto a machine with its own keys is otherwise a way to lose them. - Restores chmod each file back to its recorded mode; writeFileSync's mode applies only on create, so an existing world-readable key would otherwise stay world-readable. The adapter declares delete:false. - push warns about passphrase-less private keys before they go up. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1cfec322ac
commit
64ff854bd8
11 changed files with 879 additions and 8 deletions
141
plugins/credential-sharing/src/providers/ssh.test.ts
Normal file
141
plugins/credential-sharing/src/providers/ssh.test.ts
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { decodeSshFile, encodeSshFile, isPassphraseless, readSshDirectory, secretNameForPath, sshProvider } from "./ssh.js";
|
||||
|
||||
const PRIVATE_KEY = ["-----BEGIN OPENSSH PRIVATE KEY-----", "b3BlbnNzaC1rZXktdjEAAAAABG5vbmU=", "-----END OPENSSH PRIVATE KEY-----", ""].join("\n");
|
||||
const PUBLIC_KEY = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExample anthony@dev\n";
|
||||
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), "logicsrc-ssh-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function write(relPath: string, body: string, mode = 0o600): void {
|
||||
const target = join(dir, relPath);
|
||||
mkdirSync(join(target, ".."), { recursive: true });
|
||||
writeFileSync(target, body);
|
||||
chmodSync(target, mode);
|
||||
}
|
||||
|
||||
describe("ssh directory scanning", () => {
|
||||
it("picks up key pairs and config, and skips host-specific files", () => {
|
||||
write("id_ed25519", PRIVATE_KEY, 0o600);
|
||||
write("id_ed25519.pub", PUBLIC_KEY, 0o644);
|
||||
write("config", "Host dev\n User anthony\n", 0o600);
|
||||
write("known_hosts", "github.com ssh-ed25519 AAAAC3Nz\n", 0o644);
|
||||
write("authorized_keys", PUBLIC_KEY, 0o600);
|
||||
|
||||
const bag = readSshDirectory(dir);
|
||||
expect(Object.keys(bag).sort()).toEqual(["SSH_CONFIG", "SSH_ID_ED25519", "SSH_ID_ED25519_PUB"]);
|
||||
expect(decodeSshFile("SSH_ID_ED25519", bag.SSH_ID_ED25519)).toMatchObject({ path: "id_ed25519", mode: 0o600, kind: "private-key" });
|
||||
expect(decodeSshFile("SSH_ID_ED25519_PUB", bag.SSH_ID_ED25519_PUB)).toMatchObject({ mode: 0o644, kind: "public-key" });
|
||||
});
|
||||
|
||||
it("includes opted-in files by name", () => {
|
||||
write("authorized_keys", PUBLIC_KEY, 0o600);
|
||||
expect(Object.keys(readSshDirectory(dir, new Set(["authorized_keys"])))).toEqual(["SSH_AUTHORIZED_KEYS"]);
|
||||
});
|
||||
|
||||
it("recurses into subdirectories and keeps paths distinct", () => {
|
||||
write("keys/work_ed25519", PRIVATE_KEY);
|
||||
const bag = readSshDirectory(dir);
|
||||
expect(Object.keys(bag)).toEqual(["SSH_KEYS_WORK_ED25519"]);
|
||||
expect(decodeSshFile("SSH_KEYS_WORK_ED25519", bag.SSH_KEYS_WORK_ED25519).path).toBe("keys/work_ed25519");
|
||||
});
|
||||
|
||||
it("ignores files that are neither key material nor ssh config", () => {
|
||||
write("notes.txt", "just a scratch file\n");
|
||||
expect(readSshDirectory(dir)).toEqual({});
|
||||
});
|
||||
|
||||
it("returns an empty bag for a directory that does not exist", () => {
|
||||
expect(readSshDirectory(join(dir, "missing"))).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ssh file envelopes", () => {
|
||||
it("round-trips path, mode, kind and body", () => {
|
||||
const file = { path: "keys/id_rsa", mode: 0o600, kind: "private-key" as const, body: PRIVATE_KEY };
|
||||
expect(decodeSshFile("SSH_KEYS_ID_RSA", encodeSshFile(file))).toEqual(file);
|
||||
});
|
||||
|
||||
it("rejects a value that is not an envelope", () => {
|
||||
expect(() => decodeSshFile("SSH_CONFIG", "Host dev\n")).toThrow(/not a logicsrc ssh file envelope/);
|
||||
});
|
||||
|
||||
it("refuses an envelope from a newer logicsrc", () => {
|
||||
expect(() => decodeSshFile("SSH_CONFIG", JSON.stringify({ v: 99, path: "config", body: "x" }))).toThrow(/newer logicsrc/);
|
||||
});
|
||||
|
||||
it("names secrets legibly and stably", () => {
|
||||
expect(secretNameForPath("id_ed25519.pub")).toBe("SSH_ID_ED25519_PUB");
|
||||
expect(secretNameForPath("config")).toBe("SSH_CONFIG");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ssh provider writes", () => {
|
||||
it("restores files with their permission bits, tightening a loose existing file", async () => {
|
||||
const endpoint = { provider: "ssh", path: dir };
|
||||
write("id_ed25519", "stale\n", 0o644);
|
||||
const upserts = {
|
||||
SSH_ID_ED25519: encodeSshFile({ path: "id_ed25519", mode: 0o600, kind: "private-key", body: PRIVATE_KEY }),
|
||||
SSH_KEYS_WORK: encodeSshFile({ path: "keys/work", mode: 0o600, kind: "private-key", body: PRIVATE_KEY })
|
||||
};
|
||||
|
||||
const results = await sshProvider.write({ endpoint, upserts, deletes: [], dryRun: false });
|
||||
expect(results.every((result) => result.applied)).toBe(true);
|
||||
expect(readFileSync(join(dir, "id_ed25519"), "utf8")).toBe(PRIVATE_KEY);
|
||||
expect(statSync(join(dir, "id_ed25519")).mode & 0o777).toBe(0o600);
|
||||
expect(statSync(join(dir, "keys/work")).mode & 0o777).toBe(0o600);
|
||||
});
|
||||
|
||||
it("writes nothing on a dry run", async () => {
|
||||
const endpoint = { provider: "ssh", path: dir };
|
||||
const upserts = { SSH_CONFIG: encodeSshFile({ path: "config", mode: 0o600, kind: "config", body: "Host dev\n" }) };
|
||||
const results = await sshProvider.write({ endpoint, upserts, deletes: [], dryRun: true });
|
||||
expect(results).toEqual([{ key: "SSH_CONFIG", applied: false }]);
|
||||
expect(readSshDirectory(dir)).toEqual({});
|
||||
});
|
||||
|
||||
it("refuses a path that escapes the ssh directory", async () => {
|
||||
const endpoint = { provider: "ssh", path: dir };
|
||||
const upserts = { SSH_ESCAPE: encodeSshFile({ path: "../escaped", mode: 0o600, kind: "other", body: "nope" }) };
|
||||
const [result] = await sshProvider.write({ endpoint, upserts, deletes: [], dryRun: false });
|
||||
expect(result.applied).toBe(false);
|
||||
expect(result.error).toMatch(/resolves outside/);
|
||||
});
|
||||
|
||||
it("never deletes local key files", async () => {
|
||||
const endpoint = { provider: "ssh", path: dir };
|
||||
write("id_ed25519", PRIVATE_KEY);
|
||||
const [result] = await sshProvider.write({ endpoint, upserts: {}, deletes: ["SSH_ID_ED25519"], dryRun: false });
|
||||
expect(result.applied).toBe(false);
|
||||
expect(readFileSync(join(dir, "id_ed25519"), "utf8")).toBe(PRIVATE_KEY);
|
||||
});
|
||||
});
|
||||
|
||||
describe("passphrase detection", () => {
|
||||
it("flags an unencrypted OpenSSH key", () => {
|
||||
expect(isPassphraseless(PRIVATE_KEY)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not flag an encrypted OpenSSH key", () => {
|
||||
const encrypted = PRIVATE_KEY.replace("b3BlbnNzaC1rZXktdjEAAAAABG5vbmU=", Buffer.concat([
|
||||
Buffer.from("openssh-key-v1\0", "latin1"),
|
||||
Buffer.from([0, 0, 0, 10]),
|
||||
Buffer.from("aes256-ctr", "latin1")
|
||||
]).toString("base64"));
|
||||
expect(isPassphraseless(encrypted)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not flag a non-key body", () => {
|
||||
expect(isPassphraseless(PUBLIC_KEY)).toBe(false);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue