diff --git a/README.md b/README.md index 75e0012..2f6c26a 100644 --- a/README.md +++ b/README.md @@ -160,7 +160,7 @@ It provides read-only resources for docs and schemas, validation/example tools, - LogicSRC CLI, SDK, TUI, PWA, MCP, and curl-compatible API conventions. - CommandBoard.run reference implementation. - Monorepo-maintained plugin system. -- Credential Sharing OpenSpec for end-to-end-encrypted team vaults, .env, Doppler, Railway variables, GitHub Secrets, and sh1pt. +- Credential Sharing OpenSpec for end-to-end-encrypted team vaults, .env, Doppler, Railway variables, GitHub Secrets, sh1pt, and `~/.ssh` keys. - CoinPay as the default payment, DID, wallet, and escrow plugin. - uGig as the default jobs and gigs marketplace plugin. - c0mpute as a work-in-progress compute jobs and worker pools plugin. diff --git a/apps/logicsrc-web/contract/marketing-drift.contract.test.ts b/apps/logicsrc-web/contract/marketing-drift.contract.test.ts index 8789d3f..b9a8cb9 100644 --- a/apps/logicsrc-web/contract/marketing-drift.contract.test.ts +++ b/apps/logicsrc-web/contract/marketing-drift.contract.test.ts @@ -27,7 +27,13 @@ const MARKETING_PROOF: Record = { railway: /Railway/, "github-secrets": /GitHub Secrets/, sh1pt: /sh1pt/, - team: /[Tt]eam vault/ + team: /[Tt]eam vault/, + // Deliberately not a bare /SSH/. The provider grid renders every registry + // `name`, and this one is "Local SSH directory" -- so /SSH/ would be + // satisfied by the grid alone and this provider could ship with no copy + // written about it at all, which is the drift these tests exist to catch. + // Requiring the path or the phrase means a human wrote a sentence. + ssh: /~\/\.ssh|SSH key/ }; const REPO_ROOT = resolve(process.cwd(), "../.."); diff --git a/apps/logicsrc-web/src/lib/page-markup.ts b/apps/logicsrc-web/src/lib/page-markup.ts index 4f0d701..f28f02e 100644 --- a/apps/logicsrc-web/src/lib/page-markup.ts +++ b/apps/logicsrc-web/src/lib/page-markup.ts @@ -309,6 +309,12 @@ logicsrc teams invite acme teammate@example.com # emails an accept link logicsrc teams grant acme web prod teammate@example.com logicsrc teams pull acme web prod --env .env # download + decrypt logicsrc credentials rotate acme web prod --approve +

SSH keys, not just environment variables

+

Not every secret is a KEY=VALUE line. ~/.ssh is a directory of files whose permission bits are load-bearing — a private key restored world-readable is one OpenSSH will refuse to use. The ssh provider moves that directory through the same end-to-end-encrypted vault as everything else and puts each file back with its mode intact, so a new machine is set up rather than merely populated. Restored SSH keys can go straight into ssh-agent instead of onto disk.

+
logicsrc secrets ssh push profullstack   # back up ~/.ssh to vault ssh--<you>
+logicsrc secrets ssh list profullstack   # what the vault holds: paths, kinds, modes
+logicsrc secrets ssh pull profullstack   # restore on another machine, permissions and all
+logicsrc secrets ssh agent profullstack  # load into ssh-agent, never onto disk
${credentialProviders.map((item) => ` diff --git a/docs/credential-sharing.md b/docs/credential-sharing.md index b463f9f..1ad409b 100644 --- a/docs/credential-sharing.md +++ b/docs/credential-sharing.md @@ -57,6 +57,7 @@ doppler railway github-secrets sh1pt +ssh ``` - `.env`: read, diff, redact, and write local environment files. @@ -65,6 +66,8 @@ sh1pt - GitHub Secrets: sync repository, organization, and environment secrets. - sh1pt: sync the distribution credential vault — App Store Connect keys, Play service accounts, npm and Docker tokens, Cloudflare tokens. +- ssh: read and restore a local `~/.ssh` — key pairs, `config`, + `allowed_signers` — with permission bits preserved. `sh1pt` is the one adapter driven through a **CLI** rather than an HTTP API, because sh1pt publishes `sh1pt secret set|get|list|rm` as the interface to its @@ -81,6 +84,57 @@ stating: false`), exactly like `github-secrets`: it can be a sync target but never a source, and it supports no value-restoring rollback. +## SSH Keys + +`logicsrc secrets ssh` pairs the `ssh` adapter with a team vault, so private +keys live encrypted in a vault instead of as plaintext-on-disk files guarded +only by a passphrase — the same trade Proton Pass makes with its SSH agent. + +```bash +# Back up ~/.ssh (key pairs + config) into the vault for your username +logicsrc secrets ssh push profullstack # → vault ssh--anthony +logicsrc secrets ssh push --dry-run # show what would go up +logicsrc secrets ssh push --include authorized_keys + +# See what a vault holds — paths, kinds and modes, never key bodies +logicsrc secrets ssh list profullstack + +# Restore onto a new machine, permissions and all +logicsrc secrets ssh pull profullstack + +# Or use the keys without ever writing them to that machine's disk +logicsrc secrets ssh agent profullstack --lifetime 3600 +``` + +Key material is addressed by **person, not project**: the vault is +`ssh--`, which `teams vaults` lists as project `ssh`, env +``. One teammate's keys therefore never land in another's restore, +and sharing a key stays a deliberate `teams grant`. + +Implementation notes: + +- Each file becomes one secret whose value is a JSON envelope carrying the + relative path, permission bits, and body. The envelope exists because the + engine only hands `write()` the secrets that CHANGED — a separate manifest + secret would be missing from that set whenever a key's contents change but + the file list doesn't, leaving nowhere to look up the destination path. +- Files are selected by sniffing contents, not by filename: anything holding a + `PRIVATE KEY` block or an `ssh-*`/`ecdsa-*`/`sk-*` public key line, plus + `config`, `config.d/*` and `allowed_signers`. `known_hosts` and + `authorized_keys` are host-specific and access-granting, so they are only + included when named with `--include`. +- Both directions hold back anything that would **overwrite a file that already + differs**, and say what they skipped; `--force` opts into the overwrite. A + restore onto a machine with its own keys is otherwise a way to lose them. +- Restores recreate the directory `0700` and 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`. Removing a local key you still need is + unrecoverable from here, so deletions are reported and refused, never applied. +- `push` warns when a private key has **no passphrase**. It stays end-to-end + encrypted in the vault, but everyone granted that vault gets a ready-to-use + key. + ## Core Objects ```txt diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 1df1f2d..92d9eb3 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -27,6 +27,7 @@ import { secretsUpAction, secretsDownAction } from "./teams.js"; +import { sshAgentAction, sshListAction, sshPullAction, sshPushAction } from "./ssh.js"; import { credentialsRotateAction } from "./rotate.js"; import { boards, tasks } from "./fixtures.js"; import { print, type OutputFormat } from "./format.js"; @@ -415,7 +416,34 @@ const credentials = program // `secrets` is what people reach for; keep it pointing at the same group // rather than growing a second, divergent surface. .alias("secrets") - .description("Credential Sharing OpenSpec: portable, auditable secret sync."); + .description("Credential Sharing OpenSpec: portable, auditable secret sync.") + // Commander's usage line only ever shows the first alias, so the everyday + // spelling — `logicsrc secrets` — is only discoverable from the examples. + .addHelpText( + "after", + ` +Examples: + # .env in this directory, shared with the team + logicsrc secrets teams link link this directory to a team project/env + logicsrc secrets up push .env to the linked environment + logicsrc secrets down pull it back down + logicsrc secrets down staging pull another env of the same project + + # ~/.ssh keys and config, backed up under your username + logicsrc secrets ssh push profullstack back up ~/.ssh to vault ssh-- + logicsrc secrets ssh pull profullstack restore it on another machine + logicsrc secrets ssh agent profullstack load the keys into ssh-agent, not onto disk + + # anywhere to anywhere, one plan at a time + logicsrc secrets inspect --provider env --path .env + logicsrc secrets plan --from env --from-path .env --to railway \\ + --to-project --to-config + logicsrc secrets sync --plan dry run — no writes + logicsrc secrets sync --plan --approve + +Every command also answers to "credentials" and "creds". Full guide: docs/credential-sharing.md +` + ); function endpointFromOptions(options: Record, prefix: "" | "from" | "to"): CredentialEndpoint { const pick = (name: string) => { @@ -491,6 +519,71 @@ credentials .description("Pull the linked team environment into .env.") .action((env, options) => secretsDownAction(env, { env: options.env, format: options.format as OutputFormat })); +const secretsSsh = credentials + .command("ssh") + .description("Back up ~/.ssh keys and config to an end-to-end-encrypted vault, keyed by username.") + .addHelpText( + "after", + ` +Examples: + logicsrc secrets ssh push profullstack back up ~/.ssh to vault ssh-- + logicsrc secrets ssh push --dry-run show what would go up, write nothing + logicsrc secrets ssh push --include authorized_keys + logicsrc secrets ssh list profullstack what the vault holds: paths, kinds, modes + logicsrc secrets ssh pull profullstack restore onto this machine, permissions and all + logicsrc secrets ssh pull --force also overwrite local files that differ + logicsrc secrets ssh agent profullstack --lifetime 3600 + logicsrc secrets ssh pull profullstack anthony when this box logs in as someone else + +The vault is ssh--, so "teams vaults" lists it as project ssh, env +. Key pairs, config, config.d/* and allowed_signers travel; known_hosts +and authorized_keys need --include. Nothing that would overwrite a file which +already differs is written without --force. +` + ); + +const collect = (value: string, previous: string[]): string[] => [...previous, value]; + +/** Every ssh subcommand addresses the same `ssh--` vault the same way. */ +function withSshTarget(command: import("commander").Command): import("commander").Command { + return command + .argument("[team]", "Team slug (selected interactively when omitted)") + .argument("[username]", "Vault owner (defaults to your local username)") + .option("--dir ", "SSH directory", "~/.ssh") + .option("--format ", "table, json, or markdown", "table"); +} + +const sshOptions = (options: Record) => ({ + dir: options.dir as string, + include: options.include as string[] | undefined, + force: Boolean(options.force), + dryRun: Boolean(options.dryRun), + format: options.format as OutputFormat +}); + +withSshTarget(secretsSsh.command("push")) + .option("--include ", "Also back up this file (authorized_keys, known_hosts…); repeatable", collect, []) + .option("--force", "Overwrite vault copies that differ from the local file") + .option("--dry-run", "Show what would be pushed without writing") + .description("Push key pairs and config from ~/.ssh into the vault.") + .action((team, username, options) => sshPushAction(team, username, sshOptions(options))); + +withSshTarget(secretsSsh.command("pull")) + .option("--force", "Overwrite local files that differ from the vault copy") + .option("--dry-run", "Show what would be restored without writing") + .description("Restore key pairs and config from the vault into ~/.ssh, permissions included.") + .action((team, username, options) => sshPullAction(team, username, sshOptions(options))); + +withSshTarget(secretsSsh.command("list")) + .description("List the files an ssh vault holds — paths, kinds and modes, never key bodies.") + .action((team, username, options) => sshListAction(team, username, sshOptions(options))); + +withSshTarget(secretsSsh.command("agent")) + .option("--lifetime ", "Forget the keys after this long (ssh-add -t)") + .option("--dry-run", "List the keys that would be added without adding them") + .description("Load the vault's private keys into the running ssh-agent, without writing them to disk.") + .action((team, username, options) => sshAgentAction(team, username, { ...sshOptions(options), lifetime: options.lifetime as string | undefined })); + withEndpointOptions( credentials.command("inspect").requiredOption("--provider ", "Provider id"), "", diff --git a/packages/cli/src/ssh.test.ts b/packages/cli/src/ssh.test.ts new file mode 100644 index 0000000..efc754c --- /dev/null +++ b/packages/cli/src/ssh.test.ts @@ -0,0 +1,42 @@ +import { userInfo } from "node:os"; +import { describe, expect, it } from "vitest"; +import { keysToHoldBack, sshVaultUser, SSH_PROJECT } from "./ssh.js"; +import { vaultName } from "./teams.js"; + +describe("ssh vault addressing", () => { + it("defaults to this machine's username", () => { + expect(sshVaultUser()).toBe(userInfo().username.toLowerCase()); + }); + + it("slugifies a username into the vault charset", () => { + expect(sshVaultUser("Anthony_Young")).toBe("anthony-young"); + expect(sshVaultUser("anthony@profullstack.com")).toBe("anthony-profullstack-com"); + }); + + it("rejects a username with nothing usable in it", () => { + expect(() => sshVaultUser("!!!")).toThrow(/Could not work out a username/); + }); + + it("produces a vault name teams vaults can split back into project and env", () => { + expect(vaultName(SSH_PROJECT, sshVaultUser("anthony"))).toBe("ssh--anthony"); + }); +}); + +describe("overwrite hold-back", () => { + const entries = [ + { key: "SSH_CONFIG", op: "add" as const, destructive: false }, + { key: "SSH_ID_ED25519", op: "update" as const, destructive: true } + ]; + + it("holds back files that already differ on the far side", () => { + expect(keysToHoldBack(entries, false)).toEqual(["SSH_ID_ED25519"]); + }); + + it("overwrites everything once --force is given", () => { + expect(keysToHoldBack(entries, true)).toEqual([]); + }); + + it("never holds back a file that is only being added", () => { + expect(keysToHoldBack([entries[0]], false)).toEqual([]); + }); +}); diff --git a/packages/cli/src/ssh.ts b/packages/cli/src/ssh.ts new file mode 100644 index 0000000..ad4861a --- /dev/null +++ b/packages/cli/src/ssh.ts @@ -0,0 +1,293 @@ +import { spawnSync } from "node:child_process"; +import { homedir, userInfo } from "node:os"; +import { + createCredentialEngine, + credentialProviderRegistry, + decodeSshFile, + defaultSshDirectory, + isPassphraseless, + sshDirectory, + type CredentialDiffEntry, + type CredentialEndpoint, + type CredentialKey, + type CredentialSyncRun, + type SshFile +} from "@logicsrc/plugin-credential-sharing"; +import { print, type OutputFormat } from "./format.js"; +import { authedClient, selectOne, teamEndpoint, vaultName } from "./teams.js"; + +/** + * `logicsrc secrets ssh …` — back up `~/.ssh` into an end-to-end-encrypted team + * vault, restore it on another machine, or load the private keys straight into + * a running ssh-agent without them ever touching that machine's disk. + * + * Key material is addressed by PERSON, not by project: the vault is + * `ssh--`, so `teams vaults` reads project `ssh`, env `` + * and one teammate's keys never land in another's restore. Sharing is still + * possible, but only deliberately, through `teams grant`. + */ + +export const SSH_PROJECT = "ssh"; + +/** The env half of the vault name: a username, slugified to the vault charset. */ +export function sshVaultUser(requested?: string): string { + const raw = (requested ?? safeUsername() ?? "").trim(); + const slug = raw.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, ""); + if (!slug) { + throw new Error("Could not work out a username for the ssh vault. Pass one: logicsrc secrets ssh push "); + } + return slug; +} + +function safeUsername(): string | undefined { + try { + return userInfo().username; + } catch { + return process.env.USER ?? process.env.LOGNAME; + } +} + +/** `/home/anthony/.ssh` → `~/.ssh`, purely for readable output. */ +function tilde(path: string): string { + const home = homedir(); + return path === home || path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path; +} + +export interface SshTargetOptions { + dir?: string; + include?: string[]; + force?: boolean; + dryRun?: boolean; + format: OutputFormat; +} + +interface SshTarget { + slug: string; + user: string; + vault: string; + local: CredentialEndpoint; + remote: CredentialEndpoint; + dir: string; +} + +async function resolveTarget(team: string | undefined, user: string | undefined, options: SshTargetOptions): Promise { + let slug = team; + if (!slug) { + const { client } = authedClient(); + const { teams } = await client.listTeams(); + slug = await selectOne("Team", teams.map((candidate) => candidate.slug)); + } + const username = sshVaultUser(user); + const vault = vaultName(SSH_PROJECT, username); + const local: CredentialEndpoint = { + provider: "ssh", + path: options.dir ?? defaultSshDirectory(), + metadata: options.include?.length ? { include: options.include } : undefined + }; + return { slug, user: username, vault, local, remote: teamEndpoint(slug, vault), dir: sshDirectory(local) }; +} + +/** + * Decode every envelope at an endpoint, so output can name real file paths and + * `agent` can pick out the private keys. One read of the endpoint, reused by + * the caller and by syncSsh — for a vault that is one decrypt pass, not two. + */ +async function describeEndpoint(endpoint: CredentialEndpoint): Promise<{ keys: CredentialKey[]; files: Map }> { + const files = new Map(); + const provider = credentialProviderRegistry.get(endpoint.provider); + if (!provider) throw new Error(`Unknown credential provider: ${endpoint.provider}`); + const snapshot = await provider.inspect(endpoint); + if (snapshot.keys.length === 0 || !provider.readValues) return { keys: snapshot.keys, files }; + const values = await provider.readValues(endpoint, snapshot.keys.map((key) => key.name)); + for (const [key, value] of Object.entries(values)) { + try { + files.set(key, decodeSshFile(key, value)); + } catch { + // A non-envelope secret sharing the vault is reported by name alone. + } + } + return { keys: snapshot.keys, files }; +} + +function row(key: string, file: SshFile | undefined, op: string, applied: boolean, error?: string): Record { + return { + file: file?.path ?? key, + kind: file?.kind ?? "unknown", + mode: file ? file.mode.toString(8).padStart(4, "0") : "—", + op, + applied, + ...(error ? { error } : {}) + }; +} + +interface SyncOutcome { + run?: CredentialSyncRun; + rows: Array>; + applied: number; + skipped: string[]; +} + +/** + * Files that already exist on the far side with different contents. They are + * held back unless --force: the whole point of a key backup is that restoring + * it on a machine that already has its own keys does not quietly destroy them, + * and that pushing from one machine does not quietly replace another machine's + * backed-up key of the same name. New files are never held back. + */ +export function keysToHoldBack(entries: CredentialDiffEntry[], force: boolean): string[] { + return force ? [] : entries.filter((entry) => entry.destructive).map((entry) => entry.key); +} + +/** Plan and apply one direction of the sync, honouring the hold-back rule. */ +async function syncSsh( + from: CredentialEndpoint, + to: CredentialEndpoint, + described: Map, + options: SshTargetOptions +): Promise { + const engine = createCredentialEngine(); + const diff = await engine.diffCredentialEndpoints(from, to); + const skipped = keysToHoldBack(diff.entries, Boolean(options.force)); + const plan = await engine.createCredentialSyncPlan({ from, to, policy: { denyKeys: skipped } }); + + if (plan.changes.length === 0) { + return { rows: [], applied: 0, skipped }; + } + + if (options.dryRun) { + return { + rows: plan.changes.map((change) => row(change.key, described.get(change.key), change.op, false)), + applied: 0, + skipped + }; + } + + const approval = engine.approveCredentialSync(plan.id); + const run = await engine.runCredentialSync(plan.id, { dryRun: false, approval }); + return { + run, + rows: run.results.map((result) => row(result.key, described.get(result.key), result.op, result.applied, result.error)), + applied: run.results.filter((result) => result.applied).length, + skipped + }; +} + +function reportSkipped(skipped: string[], described: Map, hint: string): void { + if (skipped.length === 0) return; + const names = skipped.map((key) => described.get(key)?.path ?? key); + console.error(`Left ${skipped.length} file(s) alone because they already differ: ${names.join(", ")}`); + console.error(` Overwrite them with --force, ${hint}`); +} + +export async function sshPushAction(team: string | undefined, user: string | undefined, options: SshTargetOptions): Promise { + const target = await resolveTarget(team, user, options); + const { files: local } = await describeEndpoint(target.local); + if (local.size === 0) { + throw new Error(`No SSH keys or config found in ${tilde(target.dir)}. Nothing to push.`); + } + + const bare = [...local.values()].filter((file) => file.kind === "private-key" && isPassphraseless(file.body)); + if (bare.length > 0) { + console.error(`⚠️ ${bare.length} private key(s) have no passphrase: ${bare.map((file) => file.path).join(", ")}`); + console.error(" They stay end-to-end encrypted in the vault, but anyone you grant it to gets a ready-to-use key."); + } + + const outcome = await syncSsh(target.local, target.remote, local, options); + const label = `${target.slug}/${target.vault}`; + if (outcome.rows.length === 0) { + console.error(`${label} is already up to date with ${tilde(target.dir)}.`); + } else if (options.dryRun) { + console.error(`Would push ${outcome.rows.length} file(s) from ${tilde(target.dir)} to ${label}.`); + } else { + console.error(`Pushed ${outcome.applied} file(s) from ${tilde(target.dir)} to ${label} (end-to-end encrypted).`); + } + reportSkipped(outcome.skipped, local, "or leave the vault holding the other machine's copy."); + print(outcome.rows.length ? outcome.rows : [{ note: `${label} matches ${tilde(target.dir)}` }], options.format); +} + +export async function sshPullAction(team: string | undefined, user: string | undefined, options: SshTargetOptions): Promise { + const target = await resolveTarget(team, user, options); + const { files: remote } = await describeEndpoint(target.remote); + if (remote.size === 0) { + throw new Error(`${target.slug}/${target.vault} holds no SSH files yet. Back some up first: logicsrc secrets ssh push ${target.slug} ${target.user}`); + } + + const outcome = await syncSsh(target.remote, target.local, remote, options); + const label = `${target.slug}/${target.vault}`; + if (outcome.rows.length === 0) { + console.error(`${tilde(target.dir)} is already up to date with ${label}.`); + } else if (options.dryRun) { + console.error(`Would restore ${outcome.rows.length} file(s) from ${label} into ${tilde(target.dir)}.`); + } else { + console.error(`Restored ${outcome.applied} file(s) from ${label} into ${tilde(target.dir)}.`); + } + reportSkipped(outcome.skipped, remote, "which replaces the local copy."); + print(outcome.rows.length ? outcome.rows : [{ note: `${tilde(target.dir)} matches ${label}` }], options.format); +} + +/** + * List what the vault holds. Values are decrypted locally to read each file's + * path, kind and mode — never its body, which is not printed anywhere. + */ +export async function sshListAction(team: string | undefined, user: string | undefined, options: SshTargetOptions): Promise { + const target = await resolveTarget(team, user, options); + const { keys, files } = await describeEndpoint(target.remote); + if (keys.length === 0) { + print([{ note: `${target.slug}/${target.vault} holds no SSH files yet. Back some up: logicsrc secrets ssh push ${target.slug} ${target.user}` }], options.format); + return; + } + print( + keys.map((key) => { + const file = files.get(key.name); + return { + file: file?.path ?? key.name, + kind: file?.kind ?? "unknown", + mode: file ? file.mode.toString(8).padStart(4, "0") : "—", + fingerprint: key.fingerprint ?? "—", + updated: key.lastModifiedAt ?? "—" + }; + }), + options.format + ); +} + +/** + * Load the vault's private keys into the running ssh-agent over stdin, so a + * throwaway machine can use them without ever writing a key to its disk. + */ +export async function sshAgentAction( + team: string | undefined, + user: string | undefined, + options: SshTargetOptions & { lifetime?: string } +): Promise { + if (!process.env.SSH_AUTH_SOCK) { + throw new Error('No ssh-agent is running (SSH_AUTH_SOCK is unset). Start one first: eval "$(ssh-agent -s)"'); + } + const target = await resolveTarget(team, user, options); + const { files } = await describeEndpoint(target.remote); + const keys = [...files.values()].filter((file) => file.kind === "private-key"); + if (keys.length === 0) { + throw new Error(`${target.slug}/${target.vault} holds no private keys to add.`); + } + + const rows = keys.map((file) => { + if (options.dryRun) return { file: file.path, loaded: false, note: "dry run" }; + const args = options.lifetime ? ["-t", options.lifetime, "-"] : ["-"]; + const result = spawnSync("ssh-add", args, { input: file.body, stdio: ["pipe", "inherit", "pipe"], encoding: "utf8" }); + if (result.error) { + throw new Error(`Could not run ssh-add: ${result.error.message}`); + } + const stderr = (result.stderr ?? "").trim(); + return result.status === 0 + ? { file: file.path, loaded: true } + : { file: file.path, loaded: false, error: stderr || `ssh-add exited ${result.status}` }; + }); + + const loaded = rows.filter((entry) => entry.loaded).length; + console.error( + options.dryRun + ? `Would add ${keys.length} key(s) from ${target.slug}/${target.vault} to the agent.` + : `Added ${loaded}/${keys.length} key(s) from ${target.slug}/${target.vault} to the agent — nothing was written to disk.` + ); + print(rows, options.format); +} diff --git a/packages/cli/src/teams.ts b/packages/cli/src/teams.ts index 9fc6b38..da2e2cb 100644 --- a/packages/cli/src/teams.ts +++ b/packages/cli/src/teams.ts @@ -27,7 +27,7 @@ import { linkedDirectory, requireSecretsLink, writeSecretsLink } from "./secrets * only ever sees ciphertext and per-member wrapped vault keys. */ -function authedClient(): { client: TeamClient; identity: ReturnType } { +export function authedClient(): { client: TeamClient; identity: ReturnType } { const identity = requireAuth(); const client = new TeamClient({ apiUrl: resolveApiUrl(identity), token: identity.apiToken }); return { client, identity }; @@ -386,7 +386,7 @@ export async function teamsGrantAction(slug: string, project: string, env: strin print({ granted: email, team: slug, project, env, vault }, format); } -function teamEndpoint(slug: string, vault: string): CredentialEndpoint { +export function teamEndpoint(slug: string, vault: string): CredentialEndpoint { return { provider: "team", project: slug, config: vault }; } @@ -428,7 +428,7 @@ export async function teamsPullAction(slug: string, project: string, envName: st print({ team: slug, project, env: envName, vault, applied, keys: run.results.map((r) => ({ key: r.key, op: r.op, applied: r.applied })) }, options.format); } -async function selectOne(label: string, values: string[]): Promise { +export async function selectOne(label: string, values: string[]): Promise { const choices = [...new Set(values)].sort(); if (choices.length === 0) throw new Error(`No ${label.toLowerCase()} options are available.`); if (choices.length === 1) { diff --git a/plugins/credential-sharing/src/index.ts b/plugins/credential-sharing/src/index.ts index 77b28b1..1b9c6b4 100644 --- a/plugins/credential-sharing/src/index.ts +++ b/plugins/credential-sharing/src/index.ts @@ -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, diff --git a/plugins/credential-sharing/src/providers/index.ts b/plugins/credential-sharing/src/providers/index.ts index 1b5d63b..7bdd4a9 100644 --- a/plugins/credential-sharing/src/providers/index.ts +++ b/plugins/credential-sharing/src/providers/index.ts @@ -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 = 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"; diff --git a/plugins/credential-sharing/src/providers/ssh.test.ts b/plugins/credential-sharing/src/providers/ssh.test.ts new file mode 100644 index 0000000..e83279e --- /dev/null +++ b/plugins/credential-sharing/src/providers/ssh.test.ts @@ -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); + }); +}); diff --git a/plugins/credential-sharing/src/providers/ssh.ts b/plugins/credential-sharing/src/providers/ssh.ts new file mode 100644 index 0000000..750ab09 --- /dev/null +++ b/plugins/credential-sharing/src/providers/ssh.ts @@ -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 { + 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 { + 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 = new Set()): CredentialValueBag { + if (!existsSync(dir)) return {}; + const relPaths: string[] = []; + walk(dir, dir, 0, relPaths); + + const bag: CredentialValueBag = {}; + const used = new Set(); + 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 }); + } +}; diff --git a/plugins/credential-sharing/src/types.ts b/plugins/credential-sharing/src/types.ts index 957d9d0..57e54d7 100644 --- a/plugins/credential-sharing/src/types.ts +++ b/plugins/credential-sharing/src/types.ts @@ -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). */