mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-08-14 14:57:28 +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
|
|
@ -53,9 +53,21 @@ export {
|
|||
dopplerProvider,
|
||||
railwayProvider,
|
||||
githubSecretsProvider,
|
||||
sshProvider,
|
||||
teamProvider,
|
||||
parseEnv,
|
||||
applyEnv
|
||||
applyEnv,
|
||||
classifySshFile,
|
||||
decodeSshFile,
|
||||
defaultSshDirectory,
|
||||
encodeSshFile,
|
||||
isPassphraseless,
|
||||
readSshDirectory,
|
||||
secretNameForPath,
|
||||
sshDirectory,
|
||||
SSH_ENVELOPE_VERSION,
|
||||
type SshFile,
|
||||
type SshFileKind
|
||||
} from "./providers/index.js";
|
||||
export {
|
||||
TeamClient,
|
||||
|
|
|
|||
|
|
@ -4,9 +4,10 @@ import { dopplerProvider } from "./doppler.js";
|
|||
import { railwayProvider } from "./railway.js";
|
||||
import { githubSecretsProvider } from "./github-secrets.js";
|
||||
import { sh1ptProvider } from "./sh1pt.js";
|
||||
import { sshProvider } from "./ssh.js";
|
||||
import { teamProvider } from "./team.js";
|
||||
|
||||
export const credentialProviders: CredentialProvider[] = [envProvider, dopplerProvider, railwayProvider, githubSecretsProvider, sh1ptProvider, teamProvider];
|
||||
export const credentialProviders: CredentialProvider[] = [envProvider, dopplerProvider, railwayProvider, githubSecretsProvider, sh1ptProvider, sshProvider, teamProvider];
|
||||
|
||||
export const credentialProviderRegistry: Map<string, CredentialProvider> = new Map(
|
||||
credentialProviders.map((provider) => [provider.id, provider])
|
||||
|
|
@ -23,5 +24,18 @@ export function listCredentialProviderManifests(): CredentialProviderManifest[]
|
|||
}));
|
||||
}
|
||||
|
||||
export { envProvider, dopplerProvider, railwayProvider, githubSecretsProvider, sh1ptProvider, teamProvider };
|
||||
export { envProvider, dopplerProvider, railwayProvider, githubSecretsProvider, sh1ptProvider, sshProvider, teamProvider };
|
||||
export { parseEnv, applyEnv } from "./env.js";
|
||||
export {
|
||||
classifySshFile,
|
||||
decodeSshFile,
|
||||
defaultSshDirectory,
|
||||
encodeSshFile,
|
||||
isPassphraseless,
|
||||
readSshDirectory,
|
||||
secretNameForPath,
|
||||
sshDirectory,
|
||||
SSH_ENVELOPE_VERSION,
|
||||
type SshFile,
|
||||
type SshFileKind
|
||||
} from "./ssh.js";
|
||||
|
|
|
|||
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);
|
||||
});
|
||||
});
|
||||
268
plugins/credential-sharing/src/providers/ssh.ts
Normal file
268
plugins/credential-sharing/src/providers/ssh.ts
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, join, relative, resolve, sep } from "node:path";
|
||||
import { keysFromValues } from "../fingerprint.js";
|
||||
import type { CredentialEndpoint, CredentialProvider, CredentialValueBag, CredentialWriteResult } from "../types.js";
|
||||
|
||||
/**
|
||||
* The `ssh` credential provider — `~/.ssh` presented as a value bag, so key
|
||||
* material rides the same end-to-end-encrypted vaults as `.env` secrets.
|
||||
*
|
||||
* Each file becomes one secret whose value is a JSON envelope carrying the
|
||||
* relative path, the permission bits, and the file body. The envelope exists
|
||||
* because the engine only hands `write()` the secrets that actually CHANGED —
|
||||
* a separate manifest secret would be absent from that set whenever a key's
|
||||
* contents change but the file list doesn't, leaving nowhere to look up the
|
||||
* destination path. Self-describing values keep every restore total.
|
||||
*/
|
||||
|
||||
export const SSH_ENVELOPE_VERSION = 1;
|
||||
|
||||
export type SshFileKind = "private-key" | "public-key" | "config" | "other";
|
||||
|
||||
export interface SshFile {
|
||||
/** Path relative to the ssh directory, e.g. "config" or "keys/work_ed25519". */
|
||||
path: string;
|
||||
/** Permission bits to restore, e.g. 0o600. */
|
||||
mode: number;
|
||||
kind: SshFileKind;
|
||||
body: string;
|
||||
}
|
||||
|
||||
/** Files that are host-specific, regenerable, or grant access — never swept up implicitly. */
|
||||
const NEVER_IMPLICIT = /^(known_hosts|authorized_keys|environment|rc)(\.old|\.d)?$/;
|
||||
|
||||
/** Config files worth keeping even though they hold no key material. */
|
||||
const CONFIG_FILES = /^(config|allowed_signers)$/;
|
||||
|
||||
const PRIVATE_KEY = /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/;
|
||||
const PUBLIC_KEY = /^(ssh-[a-z0-9-]+|ecdsa-[a-z0-9@.-]+|sk-[a-z0-9@.-]+) [A-Za-z0-9+/]/;
|
||||
|
||||
/** Nothing in ~/.ssh is legitimately this large; the cap keeps a stray blob out. */
|
||||
const MAX_FILE_BYTES = 512 * 1024;
|
||||
const MAX_DEPTH = 4;
|
||||
|
||||
export function defaultSshDirectory(): string {
|
||||
return join(homedir(), ".ssh");
|
||||
}
|
||||
|
||||
/** Resolve an endpoint to an absolute ssh directory, expanding a leading `~`. */
|
||||
export function sshDirectory(endpoint: CredentialEndpoint): string {
|
||||
const raw = endpoint.path ?? defaultSshDirectory();
|
||||
const expanded = raw === "~" || raw.startsWith(`~${sep}`) || raw.startsWith("~/") ? join(homedir(), raw.slice(1)) : raw;
|
||||
return resolve(expanded);
|
||||
}
|
||||
|
||||
/** Extra filenames the caller opted into (authorized_keys, known_hosts, …). */
|
||||
function includeList(endpoint: CredentialEndpoint): Set<string> {
|
||||
const raw = endpoint.metadata?.include;
|
||||
return new Set(Array.isArray(raw) ? raw.filter((entry): entry is string => typeof entry === "string") : []);
|
||||
}
|
||||
|
||||
export function classifySshFile(relPath: string, body: string): SshFileKind | undefined {
|
||||
if (PRIVATE_KEY.test(body)) return "private-key";
|
||||
const base = relPath.split("/").pop() ?? relPath;
|
||||
if (CONFIG_FILES.test(base) || relPath.startsWith("config.d/")) return "config";
|
||||
if (PUBLIC_KEY.test(body)) return "public-key";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Secret name for a file. Lossy on purpose — it only has to be stable, unique
|
||||
* within one directory, and legible in a `teams vaults` listing. The exact path
|
||||
* travels inside the envelope, so nothing depends on decoding this back.
|
||||
*/
|
||||
export function secretNameForPath(relPath: string): string {
|
||||
const slug = relPath
|
||||
.replace(/[^A-Za-z0-9]+/g, "_")
|
||||
.replace(/^_+|_+$/g, "")
|
||||
.toUpperCase();
|
||||
return `SSH_${slug || "FILE"}`;
|
||||
}
|
||||
|
||||
export function encodeSshFile(file: SshFile): string {
|
||||
return JSON.stringify({
|
||||
v: SSH_ENVELOPE_VERSION,
|
||||
path: file.path,
|
||||
mode: file.mode.toString(8).padStart(4, "0"),
|
||||
kind: file.kind,
|
||||
body: file.body
|
||||
});
|
||||
}
|
||||
|
||||
export function decodeSshFile(name: string, value: string): SshFile {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(value);
|
||||
} catch {
|
||||
throw new Error(`Secret "${name}" is not a logicsrc ssh file envelope. Push it with "logicsrc secrets ssh push" first.`);
|
||||
}
|
||||
if (!isRecord(parsed) || typeof parsed.path !== "string" || typeof parsed.body !== "string") {
|
||||
throw new Error(`Secret "${name}" is not a logicsrc ssh file envelope (missing path/body).`);
|
||||
}
|
||||
if (typeof parsed.v === "number" && parsed.v > SSH_ENVELOPE_VERSION) {
|
||||
throw new Error(`Secret "${name}" was written by a newer logicsrc (envelope v${parsed.v}). Upgrade: logicsrc update.`);
|
||||
}
|
||||
const mode = typeof parsed.mode === "string" ? Number.parseInt(parsed.mode, 8) : Number(parsed.mode);
|
||||
const kind = typeof parsed.kind === "string" ? (parsed.kind as SshFileKind) : "other";
|
||||
return {
|
||||
path: parsed.path,
|
||||
mode: Number.isInteger(mode) && mode > 0 ? mode & 0o7777 : defaultMode(kind),
|
||||
kind,
|
||||
body: parsed.body
|
||||
};
|
||||
}
|
||||
|
||||
function defaultMode(kind: SshFileKind): number {
|
||||
return kind === "public-key" ? 0o644 : 0o600;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* An OpenSSH private key names its cipher in the clear; "none" means the file
|
||||
* is usable by anyone who reads it. Worth saying out loud before it is shared.
|
||||
*/
|
||||
export function isPassphraseless(body: string): boolean {
|
||||
const match = /-----BEGIN OPENSSH PRIVATE KEY-----([\s\S]*?)-----END/.exec(body);
|
||||
if (!match) return /-----BEGIN (RSA|DSA|EC) PRIVATE KEY-----/.test(body) && !/Proc-Type:.*ENCRYPTED/.test(body);
|
||||
const raw = Buffer.from(match[1].replace(/\s+/g, ""), "base64");
|
||||
const magic = "openssh-key-v1\0";
|
||||
if (raw.subarray(0, magic.length).toString("latin1") !== magic) return false;
|
||||
const cipherLength = raw.readUInt32BE(magic.length);
|
||||
return raw.subarray(magic.length + 4, magic.length + 4 + cipherLength).toString("latin1") === "none";
|
||||
}
|
||||
|
||||
function walk(dir: string, base: string, depth: number, out: string[]): void {
|
||||
if (depth > MAX_DEPTH) return;
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = readdirSync(dir);
|
||||
} catch {
|
||||
return; // unreadable directory — a partial backup beats no backup
|
||||
}
|
||||
for (const entry of entries.sort()) {
|
||||
const absolute = join(dir, entry);
|
||||
let stat;
|
||||
try {
|
||||
stat = lstatSync(absolute);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (stat.isDirectory()) {
|
||||
walk(absolute, base, depth + 1, out);
|
||||
continue;
|
||||
}
|
||||
// Sockets (agent, ControlMaster) and fifos have no content to back up.
|
||||
if (!stat.isFile() && !stat.isSymbolicLink()) continue;
|
||||
if (stat.size > MAX_FILE_BYTES) continue;
|
||||
out.push(relative(base, absolute).split(sep).join("/"));
|
||||
}
|
||||
}
|
||||
|
||||
/** Scan an ssh directory into a value bag of file envelopes. */
|
||||
export function readSshDirectory(dir: string, include: Set<string> = new Set()): CredentialValueBag {
|
||||
if (!existsSync(dir)) return {};
|
||||
const relPaths: string[] = [];
|
||||
walk(dir, dir, 0, relPaths);
|
||||
|
||||
const bag: CredentialValueBag = {};
|
||||
const used = new Set<string>();
|
||||
for (const relPath of relPaths) {
|
||||
const base = relPath.split("/").pop() ?? relPath;
|
||||
const opted = include.has(relPath) || include.has(base);
|
||||
if (NEVER_IMPLICIT.test(base) && !opted) continue;
|
||||
|
||||
let body: string;
|
||||
let mode: number;
|
||||
try {
|
||||
const absolute = join(dir, relPath);
|
||||
body = readFileSync(absolute, "utf8");
|
||||
mode = lstatSync(absolute).mode & 0o7777;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const kind = classifySshFile(relPath, body) ?? (opted ? "other" : undefined);
|
||||
if (!kind) continue;
|
||||
|
||||
let name = secretNameForPath(relPath);
|
||||
for (let suffix = 2; used.has(name); suffix += 1) {
|
||||
name = `${secretNameForPath(relPath)}_${suffix}`;
|
||||
}
|
||||
used.add(name);
|
||||
bag[name] = encodeSshFile({ path: relPath, mode, kind, body });
|
||||
}
|
||||
return bag;
|
||||
}
|
||||
|
||||
/** Reject anything that would escape the ssh directory when restored. */
|
||||
function resolveInside(dir: string, relPath: string): string {
|
||||
const target = resolve(dir, relPath);
|
||||
const rel = relative(dir, target);
|
||||
if (!rel || rel.startsWith("..") || resolve(relPath) === relPath) {
|
||||
throw new Error(`Refusing to restore "${relPath}" — it resolves outside ${dir}.`);
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
export const sshProvider: CredentialProvider = {
|
||||
id: "ssh",
|
||||
name: "Local SSH directory",
|
||||
description: "Read and restore ~/.ssh key pairs and config as secrets, with permission bits preserved.",
|
||||
capabilities: { readValues: true, readNames: true, write: true, delete: false, rollback: true, audit: false },
|
||||
authRequirements: [],
|
||||
status: "available",
|
||||
|
||||
async inspect(endpoint) {
|
||||
const values = readSshDirectory(sshDirectory(endpoint), includeList(endpoint));
|
||||
return {
|
||||
provider: "ssh",
|
||||
endpoint,
|
||||
valuesReadable: true,
|
||||
keys: keysFromValues(values),
|
||||
inspectedAt: new Date().toISOString()
|
||||
};
|
||||
},
|
||||
|
||||
async readValues(endpoint, keys) {
|
||||
const values = readSshDirectory(sshDirectory(endpoint), includeList(endpoint));
|
||||
return Object.fromEntries(keys.filter((key) => key in values).map((key) => [key, values[key]]));
|
||||
},
|
||||
|
||||
async write({ endpoint, upserts, deletes, dryRun }) {
|
||||
const dir = sshDirectory(endpoint);
|
||||
const results: CredentialWriteResult[] = [];
|
||||
// Deleting a key you still need is unrecoverable from here, so the adapter
|
||||
// declares delete:false and reports the request rather than acting on it.
|
||||
for (const key of deletes) {
|
||||
results.push({ key, applied: false, error: "the ssh provider never deletes local key files — remove them by hand" });
|
||||
}
|
||||
|
||||
if (!dryRun && Object.keys(upserts).length > 0) {
|
||||
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
for (const [key, value] of Object.entries(upserts)) {
|
||||
try {
|
||||
const file = decodeSshFile(key, value);
|
||||
const target = resolveInside(dir, file.path);
|
||||
if (!dryRun) {
|
||||
mkdirSync(dirname(target), { recursive: true, mode: 0o700 });
|
||||
writeFileSync(target, file.body, { mode: file.mode });
|
||||
// writeFileSync's mode only applies when it creates the file; an
|
||||
// existing 0644 key would otherwise stay world-readable.
|
||||
chmodSync(target, file.mode);
|
||||
}
|
||||
results.push({ key, applied: !dryRun });
|
||||
} catch (error) {
|
||||
results.push({ key, applied: false, error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
}
|
||||
return results;
|
||||
},
|
||||
|
||||
async rollback({ endpoint, preImage, dryRun }) {
|
||||
return this.write!({ endpoint, upserts: preImage, deletes: [], dryRun });
|
||||
}
|
||||
};
|
||||
|
|
@ -11,7 +11,7 @@ import type { LogicSrcPrincipal, LogicSrcPolicyDecision } from "@logicsrc/accoun
|
|||
* - Adapters declare read/write capabilities before a plan is generated.
|
||||
*/
|
||||
|
||||
export type CredentialProviderId = "env" | "doppler" | "railway" | "github-secrets" | "sh1pt" | "team" | (string & {});
|
||||
export type CredentialProviderId = "env" | "doppler" | "railway" | "github-secrets" | "sh1pt" | "ssh" | "team" | (string & {});
|
||||
|
||||
export interface CredentialProviderCapabilities {
|
||||
/** Adapter can read raw secret values (enables value-level fingerprint diffs). */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue