Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
54 changes: 54 additions & 0 deletions docs/credential-sharing.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ doppler
railway
github-secrets
sh1pt
ssh
```

- `.env`: read, diff, redact, and write local environment files.
Expand All @@ -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
Expand All @@ -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
Expand Down
47 changes: 47 additions & 0 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -491,6 +492,52 @@ 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.");

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"),
"",
Expand Down
42 changes: 42 additions & 0 deletions packages/cli/src/ssh.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
Loading
Loading