mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-10-02 20:57:03 +00:00
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:
parent
257d581331
commit
f057589d66
28 changed files with 2869 additions and 17 deletions
108
plugins/credential-sharing/src/identity.ts
Normal file
108
plugins/credential-sharing/src/identity.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue