mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-08-14 06:47:28 +00:00
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>
60 lines
2.4 KiB
TypeScript
60 lines
2.4 KiB
TypeScript
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);
|
|
});
|
|
});
|