logicsrc/plugins/credential-sharing/src/crypto.ts
Anthony Ettinger f057589d66 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>
2026-07-13 13:11:32 +00:00

135 lines
5.6 KiB
TypeScript

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