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 (#139)
* 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:
parent
1cfec322ac
commit
b1805d08e5
13 changed files with 939 additions and 10 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -27,7 +27,13 @@ const MARKETING_PROOF: Record<string, RegExp> = {
|
|||
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(), "../..");
|
||||
|
|
|
|||
|
|
@ -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</code></pre>
|
||||
<h3>SSH keys, not just environment variables</h3>
|
||||
<p>Not every secret is a <code>KEY=VALUE</code> line. <code>~/.ssh</code> 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 <code>ssh-agent</code> instead of onto disk.</p>
|
||||
<pre><code>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</code></pre>
|
||||
</article>
|
||||
<div class="soon-grid">
|
||||
${credentialProviders.map((item) => `
|
||||
|
|
|
|||
|
|
@ -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--<username>`, which `teams vaults` lists as project `ssh`, env
|
||||
`<username>`. 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
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
"",
|
||||
|
|
|
|||
42
packages/cli/src/ssh.test.ts
Normal file
42
packages/cli/src/ssh.test.ts
Normal 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
293
packages/cli/src/ssh.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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