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,168 @@
/**
* Typed client for the LogicSRC team credential-sharing API (commandboard-api,
* routes under /api/credshare). Used by both the `team` credential provider and
* the CLI `teams`/`login` commands.
*
* All secret material sent through this client is already ciphertext (or a DEK
* sealed to a member public key). The server is zero-knowledge for values.
*/
export interface TeamClientOptions {
apiUrl: string;
token?: string;
}
export interface RemoteUser {
id: string;
email: string;
publicKey: string | null;
}
export interface RemoteTeam {
id: string;
slug: string;
name: string;
}
export interface RemoteMember {
email: string;
role: "owner" | "admin" | "member";
status: "active" | "invited";
hasPublicKey: boolean;
joinedAt: string | null;
}
export interface RemoteVault {
id: string;
name: string;
hasAccess: boolean;
secretCount: number;
}
export interface RemoteSecret {
name: string;
nonce: string;
ciphertext: string;
fingerprint: string;
version: number;
updatedAt: string;
}
export interface RemoteGrantRow {
email: string;
hasPublicKey: boolean;
hasAccess: boolean;
}
export class TeamApiError extends Error {
constructor(
public readonly status: number,
message: string
) {
super(message);
this.name = "TeamApiError";
}
}
export class TeamClient {
private readonly apiUrl: string;
private token?: string;
constructor(options: TeamClientOptions) {
this.apiUrl = options.apiUrl.replace(/\/$/, "");
this.token = options.token;
}
setToken(token: string): void {
this.token = token;
}
private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
const headers: Record<string, string> = { accept: "application/json" };
if (body !== undefined) headers["content-type"] = "application/json";
if (this.token) headers["authorization"] = `Bearer ${this.token}`;
const response = await fetch(`${this.apiUrl}/api/credshare${path}`, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body)
});
const text = await response.text();
const parsed = text ? (JSON.parse(text) as unknown) : undefined;
if (!response.ok) {
const message = isRecord(parsed) && typeof parsed.error === "string" ? parsed.error : `${response.status} ${response.statusText}`;
throw new TeamApiError(response.status, message);
}
return parsed as T;
}
// ---- auth ----
requestLoginCode(email: string) {
return this.request<{ ok: boolean; emailSent: boolean; devCode?: string }>("POST", "/auth/request", { email });
}
verifyLoginCode(email: string, code: string) {
return this.request<{ token: string; user: RemoteUser }>("POST", "/auth/verify", { email, code });
}
uploadPublicKey(publicKey: string) {
return this.request<{ email: string; publicKey: string }>("POST", "/keys", { publicKey });
}
me() {
return this.request<{ user: RemoteUser; teams: RemoteTeam[] }>("GET", "/me");
}
logout() {
return this.request<{ ok: boolean }>("POST", "/logout");
}
lookupUser(email: string) {
return this.request<{ email: string; userId: string | null; publicKey: string | null }>("GET", `/users?email=${encodeURIComponent(email)}`);
}
// ---- teams / members / invites ----
createTeam(slug: string, name?: string) {
return this.request<{ team: RemoteTeam }>("POST", "/teams", { slug, name });
}
listTeams() {
return this.request<{ teams: RemoteTeam[] }>("GET", "/teams");
}
listMembers(slug: string) {
return this.request<{ members: RemoteMember[] }>("GET", `/teams/${encodeURIComponent(slug)}/members`);
}
invite(slug: string, email: string, role?: "owner" | "admin" | "member") {
return this.request<{ invite: { id: string; email: string; role: string; expiresAt: string }; emailSent: boolean; token?: string }>(
"POST",
`/teams/${encodeURIComponent(slug)}/invites`,
{ email, role }
);
}
acceptInvite(token: string) {
return this.request<{ ok: boolean; team?: RemoteTeam }>("POST", "/invites/accept", { token });
}
// ---- vaults / grants / secrets ----
listVaults(slug: string) {
return this.request<{ vaults: RemoteVault[] }>("GET", `/teams/${encodeURIComponent(slug)}/vaults`);
}
createVault(slug: string, name: string) {
return this.request<{ vault: { id: string; name: string } }>("POST", `/teams/${encodeURIComponent(slug)}/vaults`, { name });
}
getMyGrant(vaultId: string) {
return this.request<{ wrappedDek: string }>("GET", `/vaults/${encodeURIComponent(vaultId)}/grant`);
}
listGrants(vaultId: string) {
return this.request<{ grants: RemoteGrantRow[] }>("GET", `/vaults/${encodeURIComponent(vaultId)}/grants`);
}
putGrant(vaultId: string, email: string, wrappedDek: string) {
return this.request<{ ok: boolean }>("POST", `/vaults/${encodeURIComponent(vaultId)}/grants`, { email, wrappedDek });
}
listSecrets(vaultId: string) {
return this.request<{ vaultId: string; secrets: RemoteSecret[] }>("GET", `/vaults/${encodeURIComponent(vaultId)}/secrets`);
}
putSecrets(vaultId: string, upserts: Array<{ name: string; nonce: string; ciphertext: string; fingerprint: string }>, deletes: string[]) {
return this.request<{ ok: boolean; applied: string[] }>("PUT", `/vaults/${encodeURIComponent(vaultId)}/secrets`, { upserts, deletes });
}
listAudit(vaultId: string) {
return this.request<{ audit: Array<Record<string, unknown>> }>("GET", `/vaults/${encodeURIComponent(vaultId)}/audit`);
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}