feat(credential-sharing): end-to-end-encrypted team credential sharing

Adds a `team` credential provider + team/member management so teammates can
share secrets by email instead of passing .env files over chat. Fully E2E:
the server only ever stores ciphertext, per-member sealed vault keys, and
public keys — it never sees a plaintext value or the vault DEK.

Plugin (@logicsrc/plugin-credential-sharing)
- crypto.ts: X25519 identity keys, per-vault DEK (secretbox), DEK sealed to
  each member's pubkey (crypto_box_seal), value encrypt/decrypt (libsodium)
- identity.ts: local ~/.logicsrc/identity.json (0600) holding the device key
  + API token; never uploads the secret key
- client.ts: typed /api/credshare client
- providers/team.ts: `team:<slug>/<vault>` CredentialProvider (inspect,
  readValues=decrypt, write=encrypt, rollback); fingerprints match env so
  env<->team diffs line up
- fixes latent libsodium-wrappers ESM load bug (createRequire) here + in
  github-secrets

Server (commandboard-api /api/credshare)
- zero-knowledge router: email-code auth, keys, teams, members, invites,
  vaults, sealed grants, ciphertext secrets, audit; membership authz in app
- CredShareStore abstraction: in-memory (dev/tests) + Supabase (prod)
- Resend email transport for login codes + invites (no-op -> echoes locally)
- supabase migration: credshare_* tables, deny-by-default RLS

CLI
- real `logicsrc login` (email code -> token + key upload)
- `logicsrc teams create/list/invite/accept/members/vaults/grant/push/pull`

Web (logicsrc.com/teams + /teams/accept)
- management surface only (browser holds no private key, never decrypts):
  login, view teams/members/vaults, invite, accept

Tests: crypto round-trip, server contract (invite->accept->push->grant->pull
+ authz boundaries), and a real HTTP+client+crypto E2E asserting the server
never holds plaintext. Full workspace build + tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Anthony Ettinger 2026-07-13 13:09:11 +00:00
parent 257d581331
commit f057589d66
28 changed files with 2869 additions and 17 deletions

View file

@ -9,6 +9,20 @@ import { listSocialAccountProviders } from "@logicsrc/plugin-social-accounts";
import { renderArcadeList, renderPluginStatus, renderTui, runArcadeSession, type TaskSnapshot } from "@logicsrc/tui";
import { assertSchemaKind, parseDocument, validate } from "@logicsrc/validators";
import { getConfigValue, readConfig, setConfigValue, writeConfig } from "./config.js";
import {
loginAction,
logoutAction,
whoamiAction,
teamsCreateAction,
teamsListAction,
teamsInviteAction,
teamsAcceptAction,
teamsMembersAction,
teamsVaultsAction,
teamsGrantAction,
teamsPushAction,
teamsPullAction
} from "./teams.js";
import { boards, tasks } from "./fixtures.js";
import { print, type OutputFormat } from "./format.js";
import { parsePositiveInteger } from "./numeric-options.js";
@ -64,21 +78,27 @@ program.action(async (options) => {
program
.command("login")
.option("--did <did>", "CoinPay DID")
.option("--oauth <provider>", "OAuth provider")
.description("Start a login flow.")
.action((options) => {
const mode = options.did ? `CoinPay DID ${options.did}` : options.oauth ? `${options.oauth} OAuth` : "browser/device";
console.log(`Login flow ready: ${mode}`);
console.log("Token storage target: $HOME/.logicsrc/auth.json");
.option("--email <email>", "Email to log in with (LogicSRC team credential sharing)")
.option("--code <code>", "Login code (skip the interactive prompt)")
.option("--did <did>", "CoinPay DID (legacy)")
.option("--oauth <provider>", "OAuth provider (legacy)")
.description("Log in by email for team credential sharing (registers your device identity key).")
.action(async (options) => {
if (!options.email && (options.did || options.oauth)) {
const mode = options.did ? `CoinPay DID ${options.did}` : `${options.oauth} OAuth`;
console.log(`Login flow ready: ${mode}`);
console.log("Token storage target: $HOME/.logicsrc/identity.json");
return;
}
await loginAction({ email: options.email, code: options.code });
});
program.command("logout").description("Clear local auth token.").action(() => {
console.log("Logged out. Local auth token would be removed from $HOME/.logicsrc/auth.json.");
program.command("logout").description("Clear local auth token (keeps your identity key).").action(async () => {
await logoutAction();
});
program.command("whoami").description("Show current DID and account context.").action(() => {
print({ did: process.env.COMMANDBOARD_DID || "anthony.coinpay", api_url: process.env.COMMANDBOARD_API_URL || "http://localhost:4010" }, "table");
program.command("whoami").option("--format <format>", "table, json, or markdown", "table").description("Show current login + teams.").action(async (options) => {
await whoamiAction(options.format as OutputFormat);
});
program
@ -524,6 +544,79 @@ credentials
print(credentialEngine().exportCredentialAudit(options.run), options.format as OutputFormat);
});
const teams = program.command("teams").description("Share credentials with teammates by email — end-to-end encrypted team vaults.");
teams
.command("create")
.argument("<slug>", "Team slug (lowercase letters, numbers, dashes)")
.option("--name <name>", "Display name")
.option("--format <format>", "table, json, or markdown", "table")
.description("Create a team you own.")
.action((slug, options) => teamsCreateAction(slug, { name: options.name, format: options.format as OutputFormat }));
teams
.command("list")
.option("--format <format>", "table, json, or markdown", "table")
.description("List teams you belong to.")
.action((options) => teamsListAction(options.format as OutputFormat));
teams
.command("invite")
.argument("<slug>", "Team slug")
.argument("<email>", "Teammate email")
.option("--role <role>", "owner | admin | member", "member")
.option("--format <format>", "table, json, or markdown", "table")
.description("Invite a teammate by email.")
.action((slug, email, options) => teamsInviteAction(slug, email, { role: options.role, format: options.format as OutputFormat }));
teams
.command("accept")
.argument("<token>", "Invite token from your email")
.option("--format <format>", "table, json, or markdown", "table")
.description("Accept a team invite.")
.action((token, options) => teamsAcceptAction(token, options.format as OutputFormat));
teams
.command("members")
.argument("<slug>", "Team slug")
.option("--format <format>", "table, json, or markdown", "table")
.description("List team members and their status.")
.action((slug, options) => teamsMembersAction(slug, options.format as OutputFormat));
teams
.command("vaults")
.argument("<slug>", "Team slug")
.option("--format <format>", "table, json, or markdown", "table")
.description("List a team's credential vaults.")
.action((slug, options) => teamsVaultsAction(slug, options.format as OutputFormat));
teams
.command("grant")
.argument("<slug>", "Team slug")
.argument("<vault>", "Vault name")
.argument("<email>", "Teammate email to grant vault access")
.option("--format <format>", "table, json, or markdown", "table")
.description("Grant a member decryption access to a vault (re-wraps the vault key to their key).")
.action((slug, vault, email, options) => teamsGrantAction(slug, vault, email, options.format as OutputFormat));
teams
.command("push")
.argument("<slug>", "Team slug")
.argument("<vault>", "Vault name")
.option("--env <path>", "Source .env file", ".env")
.option("--format <format>", "table, json, or markdown", "table")
.description("Encrypt and push a local .env into a team vault.")
.action((slug, vault, options) => teamsPushAction(slug, vault, { env: options.env, format: options.format as OutputFormat }));
teams
.command("pull")
.argument("<slug>", "Team slug")
.argument("<vault>", "Vault name")
.option("--env <path>", "Destination .env file", ".env")
.option("--format <format>", "table, json, or markdown", "table")
.description("Pull a team vault and decrypt it into a local .env.")
.action((slug, vault, options) => teamsPullAction(slug, vault, { env: options.env, format: options.format as OutputFormat }));
const accounts = program.command("accounts").description("Manage connected social and email accounts.");
accounts

207
packages/cli/src/teams.ts Normal file
View file

@ -0,0 +1,207 @@
import { createInterface } from "node:readline/promises";
import {
TeamClient,
TeamApiError,
loadOrCreateIdentity,
readIdentity,
updateIdentity,
requireAuth,
defaultApiUrl,
createCredentialEngine,
unwrapVaultKey,
wrapVaultKey,
type CredentialEndpoint
} from "@logicsrc/plugin-credential-sharing";
import { print, type OutputFormat } from "./format.js";
/**
* `logicsrc login` + `logicsrc teams …` the team credential-sharing surface.
* Secrets are end-to-end encrypted: the server (commandboard-api /api/credshare)
* only ever sees ciphertext and per-member wrapped vault keys.
*/
function authedClient(): { client: TeamClient; identity: ReturnType<typeof requireAuth> } {
const identity = requireAuth();
const client = new TeamClient({ apiUrl: identity.apiUrl || defaultApiUrl(), token: identity.apiToken });
return { client, identity };
}
async function prompt(question: string): Promise<string> {
const rl = createInterface({ input: process.stdin, output: process.stderr });
try {
return (await rl.question(question)).trim();
} finally {
rl.close();
}
}
async function resolveVaultId(client: TeamClient, slug: string, vault: string): Promise<string> {
const { vaults } = await client.listVaults(slug);
const found = vaults.find((v) => v.name === vault);
if (!found) throw new Error(`Vault "${vault}" not found in team "${slug}". Create it by pushing to it.`);
return found.id;
}
export async function loginAction(options: { email?: string; code?: string }): Promise<void> {
const identity = await loadOrCreateIdentity();
const email = options.email ?? (await prompt("Email: "));
if (!email) throw new Error("An email is required: logicsrc login --email you@example.com");
const client = new TeamClient({ apiUrl: identity.apiUrl || defaultApiUrl() });
const requested = await client.requestLoginCode(email);
let code = options.code;
if (requested.devCode) {
// No email transport configured server-side (local dev) — the code is returned.
console.error(`(dev) login code: ${requested.devCode}`);
code = code ?? requested.devCode;
}
if (!code) code = await prompt(`Enter the 6-digit code sent to ${email}: `);
const verified = await client.verifyLoginCode(email, code);
client.setToken(verified.token);
await client.uploadPublicKey(identity.keys.publicKey);
await updateIdentity({ email: verified.user.email, userId: verified.user.id, apiToken: verified.token, apiUrl: identity.apiUrl || defaultApiUrl() });
console.error(`Logged in as ${verified.user.email}. Identity key registered.`);
print({ email: verified.user.email, userId: verified.user.id, apiUrl: identity.apiUrl || defaultApiUrl() }, "table");
}
export async function logoutAction(): Promise<void> {
const identity = readIdentity();
if (identity?.apiToken) {
try {
const client = new TeamClient({ apiUrl: identity.apiUrl || defaultApiUrl(), token: identity.apiToken });
await client.logout();
} catch {
// best effort — token may already be gone
}
}
await updateIdentity({ apiToken: undefined, email: undefined, userId: undefined });
console.error("Logged out. Local identity key retained (delete ~/.logicsrc/identity.json to remove it).");
}
export async function whoamiAction(format: OutputFormat): Promise<void> {
const identity = readIdentity();
if (!identity?.apiToken) {
print({ loggedIn: false, apiUrl: defaultApiUrl(), hint: "Run: logicsrc login --email you@example.com" }, format);
return;
}
const { client } = authedClient();
const me = await client.me();
print({ loggedIn: true, email: me.user.email, apiUrl: identity.apiUrl, publicKey: me.user.publicKey, teams: me.teams.map((t) => t.slug) }, format);
}
export async function teamsCreateAction(slug: string, options: { name?: string; format: OutputFormat }): Promise<void> {
const { client } = authedClient();
const { team } = await client.createTeam(slug, options.name);
console.error(`Created team ${team.slug}. Invite teammates: logicsrc teams invite ${team.slug} them@example.com`);
print(team, options.format);
}
export async function teamsListAction(format: OutputFormat): Promise<void> {
const { client } = authedClient();
const { teams } = await client.listTeams();
print(teams.length ? teams.map((t) => ({ slug: t.slug, name: t.name })) : [{ note: "No teams yet. Create one: logicsrc teams create <slug>" }], format);
}
export async function teamsInviteAction(slug: string, email: string, options: { role?: string; format: OutputFormat }): Promise<void> {
const { client } = authedClient();
const role = options.role as "owner" | "admin" | "member" | undefined;
const result = await client.invite(slug, email, role);
if (result.emailSent) {
console.error(`Invited ${email} to ${slug}. An email is on the way.`);
print({ invited: email, team: slug, role: result.invite.role, emailSent: true }, options.format);
} else {
console.error(`Invited ${email} to ${slug}. No email transport configured — share this accept command with them:`);
console.error(` logicsrc login --email ${email} && logicsrc teams accept ${result.token}`);
print({ invited: email, team: slug, role: result.invite.role, token: result.token }, options.format);
}
}
export async function teamsAcceptAction(token: string, format: OutputFormat): Promise<void> {
const { client } = authedClient();
const result = await client.acceptInvite(token);
console.error(`Joined ${result.team?.slug ?? "team"}. Ask a member to grant you a vault, then: logicsrc teams pull <team> <vault>`);
print({ joined: result.team?.slug ?? null }, format);
}
export async function teamsMembersAction(slug: string, format: OutputFormat): Promise<void> {
const { client } = authedClient();
const { members } = await client.listMembers(slug);
print(
members.map((m) => ({ email: m.email, role: m.role, status: m.status, hasKey: m.hasPublicKey })),
format
);
}
export async function teamsVaultsAction(slug: string, format: OutputFormat): Promise<void> {
const { client } = authedClient();
const { vaults } = await client.listVaults(slug);
print(
vaults.length ? vaults.map((v) => ({ vault: v.name, secrets: v.secretCount, youHaveAccess: v.hasAccess })) : [{ note: "No vaults yet. Push to create one: logicsrc teams push <team> <vault>" }],
format
);
}
export async function teamsGrantAction(slug: string, vault: string, email: string, format: OutputFormat): Promise<void> {
const { client, identity } = authedClient();
const vaultId = await resolveVaultId(client, slug, vault);
// Unwrap the vault DEK with our own key, then re-wrap it to the target member.
let myWrapped: string;
try {
myWrapped = (await client.getMyGrant(vaultId)).wrappedDek;
} catch (error) {
if (error instanceof TeamApiError && error.status === 403) {
throw new Error(`You don't have access to ${slug}/${vault} yourself, so you can't grant it. Ask an existing member.`);
}
throw error;
}
const dek = await unwrapVaultKey(myWrapped, identity.keys);
const target = await client.lookupUser(email);
if (!target.userId) throw new Error(`${email} has not logged in yet. Ask them to run: logicsrc login --email ${email}`);
if (!target.publicKey) throw new Error(`${email} has not registered a key yet. Ask them to run: logicsrc login --email ${email}`);
await client.putGrant(vaultId, email, await wrapVaultKey(dek, target.publicKey));
console.error(`Granted ${email} access to ${slug}/${vault}. They can now: logicsrc teams pull ${slug} ${vault}`);
print({ granted: email, team: slug, vault }, format);
}
function teamEndpoint(slug: string, vault: string): CredentialEndpoint {
return { provider: "team", project: slug, config: vault };
}
export async function teamsPushAction(slug: string, vault: string, options: { env: string; format: OutputFormat }): Promise<void> {
requireAuth();
const engine = createCredentialEngine();
const from: CredentialEndpoint = { provider: "env", path: options.env };
const plan = await engine.createCredentialSyncPlan({ from, to: teamEndpoint(slug, vault) });
if (plan.changes.length === 0) {
console.error(`${slug}/${vault} is already up to date with ${options.env}.`);
print({ team: slug, vault, changes: 0 }, options.format);
return;
}
const approval = engine.approveCredentialSync(plan.id);
const run = await engine.runCredentialSync(plan.id, { dryRun: false, approval });
const applied = run.results.filter((r) => r.applied).length;
console.error(`Pushed ${applied} secret(s) from ${options.env} to ${slug}/${vault} (end-to-end encrypted).`);
print({ team: slug, vault, applied, keys: run.results.map((r) => ({ key: r.key, op: r.op, applied: r.applied })) }, options.format);
}
export async function teamsPullAction(slug: string, vault: string, options: { env: string; format: OutputFormat }): Promise<void> {
requireAuth();
const engine = createCredentialEngine();
const to: CredentialEndpoint = { provider: "env", path: options.env };
const plan = await engine.createCredentialSyncPlan({ from: teamEndpoint(slug, vault), to });
if (plan.changes.length === 0) {
console.error(`${options.env} is already up to date with ${slug}/${vault}.`);
print({ team: slug, vault, changes: 0 }, options.format);
return;
}
const approval = engine.approveCredentialSync(plan.id);
const run = await engine.runCredentialSync(plan.id, { dryRun: false, approval });
const applied = run.results.filter((r) => r.applied).length;
console.error(`Pulled ${applied} secret(s) from ${slug}/${vault} into ${options.env}.`);
print({ team: slug, vault, applied, keys: run.results.map((r) => ({ key: r.key, op: r.op, applied: r.applied })) }, options.format);
}