Add SSH keys and config to credential sharing (#139)
Some checks are pending
CI / build (push) Waiting to run
test / test (push) Waiting to run

* 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>

* Add worked examples to secrets and secrets ssh help

Commander's usage line shows only the first alias, so `logicsrc secrets`
— the spelling people actually type — was invisible in its own help.
The examples carry it, alongside the flows worth copying: link/up/down,
the ssh backup round trip, and a plan → dry-run → approve sync.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Advertise the ssh provider on the marketing page

The marketing-drift contract failed the build because `ssh` shipped in the
provider registry with no entry in MARKETING_PROOF -- which is the test
working: it exists so a provider cannot ship while the pages people
actually land on still describe the tool without it.

The proof regex is `/~\/\.ssh|SSH key/` rather than a bare `/SSH/` on
purpose. The provider grid renders every registry `name`, and this one is
"Local SSH directory", so `/SSH/` would already be satisfied by the
generated grid and the provider could ship with no copy written about it
at all -- passing the test while failing its intent. Requiring the path or
the phrase means a human wrote a sentence.

That sentence is the new block in the credential-sharing band: ~/.ssh is a
directory of files whose permission bits are load-bearing, not a set of
KEY=VALUE lines, which is the part that makes this provider different from
the other six. README already named ~/.ssh keys, so it needed no change.

apps/logicsrc-web: 75/75 contract tests pass (was 74 passed, 1 failed).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Anthony Ettinger 2026-08-13 17:03:00 -07:00 committed by GitHub
parent 1cfec322ac
commit b1805d08e5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 939 additions and 10 deletions

View file

@ -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--<you>
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 <projectId> --to-config <environmentId>
logicsrc secrets sync --plan <planId> dry run no writes
logicsrc secrets sync --plan <planId> --approve
Every command also answers to "credentials" and "creds". Full guide: docs/credential-sharing.md
`
);
function endpointFromOptions(options: Record<string, unknown>, 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--<your username>
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--<username>, so "teams vaults" lists it as project ssh, env
<username>. 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--<username>` 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 <path>", "SSH directory", "~/.ssh")
.option("--format <format>", "table, json, or markdown", "table");
}
const sshOptions = (options: Record<string, unknown>) => ({
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 <name>", "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 <seconds>", "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>", "Provider id"),
"",

View file

@ -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([]);
});
});

293
packages/cli/src/ssh.ts Normal file
View file

@ -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--<username>`, so `teams vaults` reads project `ssh`, env `<username>`
* 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 <team> <username>");
}
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<SshTarget> {
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<string, SshFile> }> {
const files = new Map<string, SshFile>();
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<string, unknown> {
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<Record<string, unknown>>;
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<string, SshFile>,
options: SshTargetOptions
): Promise<SyncOutcome> {
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<string, SshFile>, 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<void> {
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<void> {
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<void> {
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<void> {
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);
}

View file

@ -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<typeof requireAuth> } {
export function authedClient(): { client: TeamClient; identity: ReturnType<typeof requireAuth> } {
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<string> {
export async function selectOne(label: string, values: string[]): Promise<string> {
const choices = [...new Set(values)].sort();
if (choices.length === 0) throw new Error(`No ${label.toLowerCase()} options are available.`);
if (choices.length === 1) {