mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-08-14 23:07:29 +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
168
plugins/credential-sharing/src/client.ts
Normal file
168
plugins/credential-sharing/src/client.ts
Normal 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);
|
||||
}
|
||||
60
plugins/credential-sharing/src/crypto.test.ts
Normal file
60
plugins/credential-sharing/src/crypto.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
135
plugins/credential-sharing/src/crypto.ts
Normal file
135
plugins/credential-sharing/src/crypto.ts
Normal 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);
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
160
plugins/credential-sharing/src/providers/team.ts
Normal file
160
plugins/credential-sharing/src/providers/team.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue