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);
}

View file

@ -0,0 +1,60 @@
import { describe, it, expect } from "vitest";
import {
generateIdentityKeyPair,
generateVaultKey,
wrapVaultKey,
unwrapVaultKey,
encryptValue,
decryptValue,
publicKeyForSecret
} from "./crypto.js";
describe("credential-sharing crypto (E2E)", () => {
it("derives the public key from the secret key", async () => {
const kp = await generateIdentityKeyPair();
expect(await publicKeyForSecret(kp.secretKey)).toBe(kp.publicKey);
});
it("wraps a DEK to a member and only that member can unwrap it", async () => {
const alice = await generateIdentityKeyPair();
const mallory = await generateIdentityKeyPair();
const dek = await generateVaultKey();
const wrapped = await wrapVaultKey(dek, alice.publicKey);
expect(await unwrapVaultKey(wrapped, alice)).toBe(dek);
// A different keypair cannot open a box sealed to Alice.
await expect(unwrapVaultKey(wrapped, mallory)).rejects.toThrow();
});
it("round-trips a secret value under the vault DEK", async () => {
const dek = await generateVaultKey();
const sealed = await encryptValue("super-secret-token", dek);
expect(sealed.ciphertext).not.toContain("super-secret-token");
expect(await decryptValue(sealed, dek)).toBe("super-secret-token");
});
it("re-wrapping a DEK to a new member grants them decryption (the grant flow)", async () => {
const owner = await generateIdentityKeyPair();
const invitee = await generateIdentityKeyPair();
const dek = await generateVaultKey();
const sealed = await encryptValue("DATABASE_URL=postgres://…", dek);
// Owner wraps to self, stores ciphertext. Later the owner grants the invitee:
const ownerWrapped = await wrapVaultKey(dek, owner.publicKey);
const ownerDek = await unwrapVaultKey(ownerWrapped, owner);
const inviteeWrapped = await wrapVaultKey(ownerDek, invitee.publicKey);
// Invitee unwraps with their own key and decrypts the same ciphertext.
const inviteeDek = await unwrapVaultKey(inviteeWrapped, invitee);
expect(await decryptValue(sealed, inviteeDek)).toBe("DATABASE_URL=postgres://…");
});
it("produces distinct nonces/ciphertext for the same value", async () => {
const dek = await generateVaultKey();
const a = await encryptValue("same", dek);
const b = await encryptValue("same", dek);
expect(a.nonce).not.toBe(b.nonce);
expect(a.ciphertext).not.toBe(b.ciphertext);
});
});

View file

@ -0,0 +1,135 @@
/**
* End-to-end credential-sharing crypto for LogicSRC team vaults.
*
* Trust model (1Password-style): the server only ever stores ciphertext and
* member public keys. Secret values are encrypted client-side and only decrypt
* on a device that holds the member's private key.
*
* Scheme
* ------
* - Each MEMBER has an X25519 identity keypair. The public key is uploaded to
* the server; the secret key never leaves the member's machine.
* - Each VAULT has a symmetric data-encryption key (the DEK, 32 bytes).
* - Each SECRET VALUE is encrypted with the vault DEK using crypto_secretbox
* (XSalsa20-Poly1305) under a fresh random nonce.
* - The vault DEK is WRAPPED to each member with crypto_box_seal (anonymous
* sealed box) against that member's public key. The server stores one wrapped
* DEK per member; only that member can open it. The server never sees the DEK.
*
* Granting a new member access = an existing member unwraps the DEK with their
* secret key and re-wraps (seals) it to the new member's public key. The DEK
* plaintext exists only in memory on an already-authorized member's machine.
*/
type Sodium = {
ready: Promise<void>;
base64_variants: { ORIGINAL: number };
crypto_secretbox_NONCEBYTES: number;
crypto_secretbox_KEYBYTES: number;
from_base64(input: string, variant: number): Uint8Array;
to_base64(input: Uint8Array, variant: number): string;
from_string(input: string): Uint8Array;
to_string(input: Uint8Array): string;
randombytes_buf(length: number): Uint8Array;
crypto_box_keypair(): { publicKey: Uint8Array; privateKey: Uint8Array };
crypto_box_seal(message: Uint8Array, publicKey: Uint8Array): Uint8Array;
crypto_box_seal_open(ciphertext: Uint8Array, publicKey: Uint8Array, privateKey: Uint8Array): Uint8Array;
crypto_secretbox_easy(message: Uint8Array, nonce: Uint8Array, key: Uint8Array): Uint8Array;
crypto_secretbox_open_easy(ciphertext: Uint8Array, nonce: Uint8Array, key: Uint8Array): Uint8Array;
crypto_scalarmult_base(privateKey: Uint8Array): Uint8Array;
};
let sodiumPromise: Promise<Sodium> | undefined;
async function loadSodium(): Promise<Sodium> {
if (!sodiumPromise) {
sodiumPromise = (async () => {
// libsodium-wrappers ships a broken ESM entry (its .mjs imports a sibling
// libsodium.mjs that isn't published). Load the self-contained CJS build
// via createRequire so this works under both native ESM and vitest.
const { createRequire } = await import("node:module");
const require = createRequire(import.meta.url);
const mod = require("libsodium-wrappers") as { default?: Sodium } & Sodium;
const sodium: Sodium = mod.default ?? mod;
await sodium.ready;
return sodium;
})();
}
return sodiumPromise;
}
function b64(sodium: Sodium, bytes: Uint8Array): string {
return sodium.to_base64(bytes, sodium.base64_variants.ORIGINAL);
}
function unb64(sodium: Sodium, value: string): Uint8Array {
return sodium.from_base64(value, sodium.base64_variants.ORIGINAL);
}
/** A member identity keypair, base64-encoded for storage/transport. */
export interface IdentityKeyPair {
publicKey: string;
secretKey: string;
}
/** A single secret value encrypted under a vault DEK. */
export interface SealedValue {
nonce: string;
ciphertext: string;
}
/** Generate a fresh X25519 identity keypair for a member. */
export async function generateIdentityKeyPair(): Promise<IdentityKeyPair> {
const sodium = await loadSodium();
const { publicKey, privateKey } = sodium.crypto_box_keypair();
return { publicKey: b64(sodium, publicKey), secretKey: b64(sodium, privateKey) };
}
/** Derive the public key for a stored secret key (used to validate an identity file). */
export async function publicKeyForSecret(secretKeyB64: string): Promise<string> {
const sodium = await loadSodium();
return b64(sodium, sodium.crypto_scalarmult_base(unb64(sodium, secretKeyB64)));
}
/** Generate a new random vault data-encryption key (raw bytes, base64). */
export async function generateVaultKey(): Promise<string> {
const sodium = await loadSodium();
return b64(sodium, sodium.randombytes_buf(sodium.crypto_secretbox_KEYBYTES));
}
/** Seal (wrap) a vault DEK to a member's public key. Only their secret key opens it. */
export async function wrapVaultKey(dekB64: string, memberPublicKeyB64: string): Promise<string> {
const sodium = await loadSodium();
const sealed = sodium.crypto_box_seal(unb64(sodium, dekB64), unb64(sodium, memberPublicKeyB64));
return b64(sodium, sealed);
}
/** Open a wrapped vault DEK with the member's own keypair. */
export async function unwrapVaultKey(wrappedB64: string, identity: IdentityKeyPair): Promise<string> {
const sodium = await loadSodium();
const dek = sodium.crypto_box_seal_open(
unb64(sodium, wrappedB64),
unb64(sodium, identity.publicKey),
unb64(sodium, identity.secretKey)
);
return b64(sodium, dek);
}
/** Encrypt a raw secret value with the vault DEK. */
export async function encryptValue(value: string, dekB64: string): Promise<SealedValue> {
const sodium = await loadSodium();
const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
const ciphertext = sodium.crypto_secretbox_easy(sodium.from_string(value), nonce, unb64(sodium, dekB64));
return { nonce: b64(sodium, nonce), ciphertext: b64(sodium, ciphertext) };
}
/** Decrypt a sealed secret value with the vault DEK. */
export async function decryptValue(sealed: SealedValue, dekB64: string): Promise<string> {
const sodium = await loadSodium();
const plain = sodium.crypto_secretbox_open_easy(
unb64(sodium, sealed.ciphertext),
unb64(sodium, sealed.nonce),
unb64(sodium, dekB64)
);
return sodium.to_string(plain);
}

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;
}

View file

@ -53,9 +53,44 @@ export {
dopplerProvider,
railwayProvider,
githubSecretsProvider,
teamProvider,
parseEnv,
applyEnv
} from "./providers/index.js";
export {
TeamClient,
TeamApiError,
type TeamClientOptions,
type RemoteUser,
type RemoteTeam,
type RemoteMember,
type RemoteVault,
type RemoteSecret,
type RemoteGrantRow
} from "./client.js";
export {
generateIdentityKeyPair,
generateVaultKey,
wrapVaultKey,
unwrapVaultKey,
encryptValue,
decryptValue,
publicKeyForSecret,
type IdentityKeyPair,
type SealedValue
} from "./crypto.js";
export {
loadOrCreateIdentity,
readIdentity,
saveIdentity,
updateIdentity,
requireAuth,
verifyIdentityIntegrity,
identityPath,
logicsrcHome,
defaultApiUrl,
type LocalIdentity
} from "./identity.js";
export {
createFileCredentialStore,
createMemoryCredentialStore,

View file

@ -17,6 +17,6 @@ export const credentialSharingManifest: PluginManifest = {
"credentials.audit.read",
"credentials.export"
],
commands: ["credentials"],
env: ["DOPPLER_TOKEN", "RAILWAY_TOKEN", "GITHUB_TOKEN", "LOGICSRC_CREDENTIAL_HOME"]
commands: ["credentials", "teams"],
env: ["DOPPLER_TOKEN", "RAILWAY_TOKEN", "GITHUB_TOKEN", "LOGICSRC_CREDENTIAL_HOME", "LOGICSRC_HOME", "COMMANDBOARD_API_URL", "LOGICSRC_API_URL"]
};

View file

@ -45,7 +45,10 @@ interface PublicKey {
}
async function sealValue(value: string, publicKeyB64: string): Promise<string> {
const sodiumModule = (await import("libsodium-wrappers")) as unknown as { default?: SodiumLike } & SodiumLike;
// libsodium-wrappers' ESM entry is broken; load its self-contained CJS build.
const { createRequire } = await import("node:module");
const require = createRequire(import.meta.url);
const sodiumModule = require("libsodium-wrappers") as { default?: SodiumLike } & SodiumLike;
const sodium: SodiumLike = sodiumModule.default ?? sodiumModule;
await sodium.ready;
const key = sodium.from_base64(publicKeyB64, sodium.base64_variants.ORIGINAL);

View file

@ -3,8 +3,9 @@ import { envProvider } from "./env.js";
import { dopplerProvider } from "./doppler.js";
import { railwayProvider } from "./railway.js";
import { githubSecretsProvider } from "./github-secrets.js";
import { teamProvider } from "./team.js";
export const credentialProviders: CredentialProvider[] = [envProvider, dopplerProvider, railwayProvider, githubSecretsProvider];
export const credentialProviders: CredentialProvider[] = [envProvider, dopplerProvider, railwayProvider, githubSecretsProvider, teamProvider];
export const credentialProviderRegistry: Map<string, CredentialProvider> = new Map(
credentialProviders.map((provider) => [provider.id, provider])
@ -21,5 +22,5 @@ export function listCredentialProviderManifests(): CredentialProviderManifest[]
}));
}
export { envProvider, dopplerProvider, railwayProvider, githubSecretsProvider };
export { envProvider, dopplerProvider, railwayProvider, githubSecretsProvider, teamProvider };
export { parseEnv, applyEnv } from "./env.js";

View file

@ -0,0 +1,160 @@
import { fingerprintValue } from "../fingerprint.js";
import { TeamClient, TeamApiError, type RemoteSecret } from "../client.js";
import { requireAuth, defaultApiUrl } from "../identity.js";
import { generateVaultKey, wrapVaultKey, unwrapVaultKey, encryptValue, decryptValue } from "../crypto.js";
import type { LocalIdentity } from "../identity.js";
import type {
CredentialEndpoint,
CredentialProvider,
CredentialSnapshot,
CredentialValueBag,
CredentialWriteResult
} from "../types.js";
/**
* The `team` credential provider an end-to-end-encrypted team vault addressed
* as `team:<team-slug>/<vault-name>` (endpoint.project = slug, endpoint.config =
* vault). Secret values are encrypted/decrypted on THIS machine with the vault
* DEK; the server only ever sees ciphertext and the DEK sealed to member keys.
*
* Auth + identity come from the local `~/.logicsrc/identity.json` (via
* `logicsrc login`), mirroring how `env` reads files and `github-secrets` reads
* GITHUB_TOKEN the provider is pure I/O over ambient credentials.
*/
interface TeamContext {
client: TeamClient;
identity: LocalIdentity & { apiToken: string; email: string };
}
function context(): TeamContext {
const identity = requireAuth();
return { client: new TeamClient({ apiUrl: identity.apiUrl || defaultApiUrl(), token: identity.apiToken }), identity };
}
function slugAndVault(endpoint: CredentialEndpoint): { slug: string; vault: string } {
const slug = endpoint.project;
const vault = endpoint.config;
if (!slug || !vault) {
throw new Error('A team endpoint needs a team and vault: team:<team-slug>/<vault-name> (e.g. team:acme/prod).');
}
return { slug, vault };
}
async function resolveVaultId(ctx: TeamContext, slug: string, vault: string, create: boolean): Promise<string | undefined> {
const { vaults } = await ctx.client.listVaults(slug);
const found = vaults.find((v) => v.name === vault);
if (found) return found.id;
if (!create) return undefined;
const created = await ctx.client.createVault(slug, vault);
return created.vault.id;
}
/** Fetch (or, for a brand-new vault, mint) the vault DEK, decrypting nothing yet. */
async function acquireDek(ctx: TeamContext, slug: string, vault: string, vaultId: string, allowMint: boolean): Promise<string> {
try {
const { wrappedDek } = await ctx.client.getMyGrant(vaultId);
return unwrapVaultKey(wrappedDek, ctx.identity.keys);
} catch (error) {
if (!(error instanceof TeamApiError) || error.status !== 403) throw error;
// No grant yet. If nobody holds the DEK, this is a fresh vault we can own.
const { grants } = await ctx.client.listGrants(vaultId);
const someoneHasAccess = grants.some((g) => g.hasAccess);
if (someoneHasAccess || !allowMint) {
throw new Error(
`You don't have access to team:${slug}/${vault} yet. Ask a member to run:\n logicsrc teams grant ${slug} ${vault} ${ctx.identity.email}`
);
}
const dek = await generateVaultKey();
const wrapped = await wrapVaultKey(dek, ctx.identity.keys.publicKey);
await ctx.client.putGrant(vaultId, ctx.identity.email, wrapped);
return dek;
}
}
export const teamProvider: CredentialProvider = {
id: "team",
name: "LogicSRC Team Vault",
description: "End-to-end-encrypted team credential vault. Share secrets with teammates by email — the server never sees plaintext.",
status: "available",
authRequirements: ["logicsrc login (identity at ~/.logicsrc/identity.json)"],
capabilities: { readValues: true, readNames: true, write: true, delete: true, rollback: true, audit: true },
async inspect(endpoint: CredentialEndpoint): Promise<CredentialSnapshot> {
const ctx = context();
const { slug, vault } = slugAndVault(endpoint);
const vaultId = await resolveVaultId(ctx, slug, vault, false);
if (!vaultId) {
return { provider: "team", endpoint, valuesReadable: true, keys: [], inspectedAt: new Date().toISOString() };
}
const { secrets } = await ctx.client.listSecrets(vaultId);
// Whether we can actually decrypt (i.e. hold a grant) determines valuesReadable.
let valuesReadable = true;
try {
await ctx.client.getMyGrant(vaultId);
} catch {
valuesReadable = false;
}
return {
provider: "team",
endpoint,
valuesReadable,
keys: secrets
.map((s: RemoteSecret) => ({ name: s.name, present: true, fingerprint: s.fingerprint, lastModifiedAt: s.updatedAt }))
.sort((a, b) => a.name.localeCompare(b.name)),
inspectedAt: new Date().toISOString()
};
},
async readValues(endpoint: CredentialEndpoint, keys: string[]): Promise<CredentialValueBag> {
const ctx = context();
const { slug, vault } = slugAndVault(endpoint);
const vaultId = await resolveVaultId(ctx, slug, vault, false);
if (!vaultId) return {};
const dek = await acquireDek(ctx, slug, vault, vaultId, false);
const { secrets } = await ctx.client.listSecrets(vaultId);
const wanted = new Set(keys);
const bag: CredentialValueBag = {};
for (const secret of secrets) {
if (!wanted.has(secret.name)) continue;
bag[secret.name] = await decryptValue({ nonce: secret.nonce, ciphertext: secret.ciphertext }, dek);
}
return bag;
},
async write(input): Promise<CredentialWriteResult[]> {
const ctx = context();
const { slug, vault } = slugAndVault(input.endpoint);
const upsertNames = Object.keys(input.upserts);
const results: CredentialWriteResult[] = [];
if (input.dryRun) {
for (const name of upsertNames) results.push({ key: name, applied: false });
for (const name of input.deletes) results.push({ key: name, applied: false });
return results;
}
const vaultId = await resolveVaultId(ctx, slug, vault, true);
if (!vaultId) throw new Error(`Could not resolve or create vault team:${slug}/${vault}.`);
if (upsertNames.length === 0 && input.deletes.length === 0) return results;
const dek = upsertNames.length > 0 ? await acquireDek(ctx, slug, vault, vaultId, true) : undefined;
const encrypted: Array<{ name: string; nonce: string; ciphertext: string; fingerprint: string }> = [];
for (const name of upsertNames) {
const sealed = await encryptValue(input.upserts[name], dek!);
encrypted.push({ name, nonce: sealed.nonce, ciphertext: sealed.ciphertext, fingerprint: fingerprintValue(input.upserts[name]) });
}
const { applied } = await ctx.client.putSecrets(vaultId, encrypted, input.deletes);
const appliedSet = new Set(applied);
for (const name of upsertNames) results.push({ key: name, applied: appliedSet.has(name) });
for (const name of input.deletes) results.push({ key: name, applied: appliedSet.has(name) });
return results;
},
async rollback(input): Promise<CredentialWriteResult[]> {
// Restoring a pre-image is just re-encrypting prior values under the DEK.
return this.write!({ endpoint: input.endpoint, upserts: input.preImage, deletes: [], dryRun: input.dryRun });
}
};