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

@ -0,0 +1,108 @@
import { mkdirSync, readFileSync, writeFileSync, existsSync, chmodSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { generateIdentityKeyPair, publicKeyForSecret, type IdentityKeyPair } from "./crypto.js";
/**
* Local, machine-bound member identity for team credential sharing.
*
* Stored at `$LOGICSRC_HOME/identity.json` (default `~/.logicsrc/identity.json`),
* mode 0600 — it holds the member's X25519 SECRET key and the server API token.
* The secret key never leaves this file; only the public key is uploaded.
*/
export interface LocalIdentity {
/** Server base URL this identity is registered against. */
apiUrl: string;
/** The member's email (their team-membership handle). */
email?: string;
/** Server-assigned user id, once logged in. */
userId?: string;
/** Opaque bearer token for the credshare API. */
apiToken?: string;
/** X25519 identity keypair (base64). */
keys: IdentityKeyPair;
createdAt: string;
updatedAt: string;
}
export function logicsrcHome(): string {
if (process.env.LOGICSRC_HOME) {
return resolve(process.env.LOGICSRC_HOME);
}
return join(homedir(), ".logicsrc");
}
export function identityPath(): string {
return process.env.LOGICSRC_IDENTITY_FILE
? resolve(process.env.LOGICSRC_IDENTITY_FILE)
: join(logicsrcHome(), "identity.json");
}
export function defaultApiUrl(): string {
return process.env.COMMANDBOARD_API_URL || process.env.LOGICSRC_API_URL || "http://localhost:4010";
}
function writeSecure(file: string, data: unknown): void {
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
writeFileSync(file, JSON.stringify(data, null, 2), { mode: 0o600 });
// Ensure 0600 even if the file already existed with looser perms.
chmodSync(file, 0o600);
}
export function readIdentity(file = identityPath()): LocalIdentity | undefined {
if (!existsSync(file)) {
return undefined;
}
return JSON.parse(readFileSync(file, "utf8")) as LocalIdentity;
}
/**
* Load the local identity, creating a fresh keypair on first use. Callers still
* need to `logicsrc login` to attach an email/token, but the keypair exists
* immediately so the public key can be uploaded during login.
*/
export async function loadOrCreateIdentity(file = identityPath()): Promise<LocalIdentity> {
const existing = readIdentity(file);
if (existing?.keys?.secretKey) {
return existing;
}
const now = new Date().toISOString();
const identity: LocalIdentity = {
apiUrl: defaultApiUrl(),
keys: await generateIdentityKeyPair(),
createdAt: now,
updatedAt: now
};
writeSecure(file, identity);
return identity;
}
export function saveIdentity(identity: LocalIdentity, file = identityPath()): void {
writeSecure(file, { ...identity, updatedAt: new Date().toISOString() });
}
/** Update fields on the stored identity, creating the keypair if absent. */
export async function updateIdentity(
patch: Partial<Omit<LocalIdentity, "keys" | "createdAt">>,
file = identityPath()
): Promise<LocalIdentity> {
const current = await loadOrCreateIdentity(file);
const next: LocalIdentity = { ...current, ...patch, updatedAt: new Date().toISOString() };
writeSecure(file, next);
return next;
}
/** Require a logged-in identity (token present), or throw with guidance. */
export function requireAuth(file = identityPath()): LocalIdentity & { apiToken: string; email: string } {
const identity = readIdentity(file);
if (!identity?.apiToken || !identity.email) {
throw new Error('Not logged in. Run "logicsrc login --email you@example.com" first.');
}
return identity as LocalIdentity & { apiToken: string; email: string };
}
/** Sanity-check that a stored identity's public key matches its secret key. */
export async function verifyIdentityIntegrity(identity: LocalIdentity): Promise<boolean> {
const derived = await publicKeyForSecret(identity.keys.secretKey);
return derived === identity.keys.publicKey;
}