feat(credentials): re-key team vaults, and reach sh1pt's vault as a provider (#117)

Two additions to Credential Sharing.

`logicsrc credentials rotate` (alias `logicsrc secrets rotate`) re-keys a
team vault: fresh DEK, re-sealed to the members who keep access, every
secret re-encrypted under it. Values do not change, so nothing that
consumes them breaks; what changes is that every wrapped key issued
before the rotation is dead. --active (the default) keeps only active
members and revokes the rest -- the "someone left" rotation. --all keeps
everyone who holds access, for plain hygiene. Dry run by default, like
`sync`.

The DEK is recoverable ONLY through the grants, so a half-applied
rotation makes a vault permanently unreadable by everyone. The whole next
state therefore goes to the server in one request and commits in one
transaction (new db.batch helper). The server also requires every
submitted fingerprint to equal the stored one: it cannot see values, but
it can prove a re-key did not swap any. Rotations that would leave the
caller ungranted, grant nobody, or cover the wrong secret count are
rejected before anything is written. GET /vaults/:id/grants now returns
publicKey and status so a client can re-seal in one pass instead of N+1
user lookups, and revocation finally deletes the grant row rather than
leaving one that reports access it no longer confers.

The sh1pt adapter is the fifth provider. It is the only one driven
through a CLI rather than HTTP, because sh1pt publishes
`sh1pt secret set|get|list|rm` as the interface to its vault and
documents no REST endpoint. Values go over the child's stdin, never argv
-- a secret in argv is readable by any user on the host via ps. Since
`sh1pt secret get` needs interactive confirmation it cannot be scripted,
so the adapter is write-only for values like github-secrets: a sync
target, never a source, no value-restoring rollback.

Tests drive a real fake sh1pt binary rather than a mocked execFile, which
is how the hang surfaced: with nothing to pipe, stdin was left open and
any subcommand that reads it would wait forever. It is now always closed.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Anthony Ettinger 2026-07-30 18:41:03 -07:00 committed by GitHub
parent f9ebf9b342
commit 6f23dbdb0f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 1278 additions and 7 deletions

View file

@ -24,6 +24,7 @@ import {
teamsPushAction,
teamsPullAction
} from "./teams.js";
import { credentialsRotateAction } from "./rotate.js";
import { boards, tasks } from "./fixtures.js";
import { print, type OutputFormat } from "./format.js";
import { parsePositiveInteger } from "./numeric-options.js";
@ -404,7 +405,13 @@ program.command("plugins").option("--format <format>", "table, json, or markdown
print(snapshot.plugins, options.format as OutputFormat);
});
const credentials = program.command("credentials").alias("creds").description("Credential Sharing OpenSpec: portable, auditable secret sync.");
const credentials = program
.command("credentials")
.alias("creds")
// `secrets` is what people reach for; keep it pointing at the same group
// rather than growing a second, divergent surface.
.alias("secrets")
.description("Credential Sharing OpenSpec: portable, auditable secret sync.");
function endpointFromOptions(options: Record<string, unknown>, prefix: "" | "from" | "to"): CredentialEndpoint {
const pick = (name: string) => {
@ -543,6 +550,25 @@ credentials
print(credentialEngine().exportCredentialAudit(options.run), options.format as OutputFormat);
});
credentials
.command("rotate")
.argument("<team>", "Team slug")
.argument("[project]", "Project name (omit with env to rotate every vault in the team)")
.argument("[env]", "Environment name (prod, staging, …)")
.option("--all", "Re-seal to everyone who holds access today, whatever their member status")
.option("--active", "Re-seal only to active members, dropping the rest (default)")
.option("--approve", "Apply the rotation (dry run by default)")
.option("--format <format>", "table, json, or markdown", "table")
.description("Re-key a vault: new vault key, secrets re-encrypted, values unchanged.")
.action((team, project, env, options) =>
credentialsRotateAction(team, project, env, {
// --all widens the keep-list; --active is the default and needs no flag.
scope: options.all ? "all" : "active",
approve: Boolean(options.approve),
format: options.format as OutputFormat
})
);
credentials
.command("export")
.requiredOption("--run <id>", "Sync run id")

156
packages/cli/src/rotate.ts Normal file
View file

@ -0,0 +1,156 @@
import {
TeamClient,
TeamApiError,
requireAuth,
resolveApiUrl,
planVaultRekey,
type RekeyMember
} from "@logicsrc/plugin-credential-sharing";
import { print, type OutputFormat } from "./format.js";
import { vaultName } from "./teams.js";
/**
* `logicsrc credentials rotate` re-key a team vault.
*
* Rotation replaces the vault's data-encryption key and re-encrypts every
* secret under it. Secret VALUES do not change, so nothing that consumes them
* breaks; what changes is that every wrapped key issued before now is dead.
* That is what makes it the right move after someone leaves a team, or on a
* schedule as plain hygiene.
*
* All crypto happens here, on the member's machine. The server receives the
* finished state (ciphertext plus sealed keys) and commits it in one
* transaction.
*/
export interface RotateOptions {
/** "active" (default) drops non-active members; "all" keeps everyone. */
scope: "active" | "all";
/** Rotation only writes with --approve, matching `credentials sync`. */
approve: boolean;
format: OutputFormat;
}
interface VaultTarget {
id: string;
name: string;
}
function authedClient(): { client: TeamClient; identity: ReturnType<typeof requireAuth> } {
const identity = requireAuth();
return { client: new TeamClient({ apiUrl: resolveApiUrl(identity), token: identity.apiToken }), identity };
}
/**
* Which vaults this invocation covers: one when a project/env pair is given,
* otherwise every vault in the team the caller can actually open.
*/
async function resolveTargets(
client: TeamClient,
slug: string,
project?: string,
env?: string
): Promise<VaultTarget[]> {
const { vaults } = await client.listVaults(slug);
if (project || env) {
if (!project || !env) {
throw new Error("Give both a project and an env, or neither to rotate the whole team: logicsrc creds rotate <team> [project] [env]");
}
const name = vaultName(project, env);
const found = vaults.find((v) => v.name === name);
if (!found) {
const known = vaults.map((v) => v.name).join(", ");
throw new Error(`Vault "${name}" not found in team "${slug}".${known ? ` Existing vaults: ${known}.` : ""}`);
}
return [{ id: found.id, name: found.name }];
}
// Vaults the caller holds no grant on cannot be re-keyed by them; skip rather
// than fail the whole sweep.
const accessible = vaults.filter((v) => v.hasAccess);
if (accessible.length === 0) {
throw new Error(`No vaults in "${slug}" that you have access to. Ask a member to grant you, or name a vault explicitly.`);
}
return accessible.map((v) => ({ id: v.id, name: v.name }));
}
async function rotateOne(
client: TeamClient,
identity: ReturnType<typeof requireAuth>,
vault: VaultTarget,
options: RotateOptions
): Promise<Record<string, unknown>> {
let myWrappedDek: string;
try {
myWrappedDek = (await client.getMyGrant(vault.id)).wrappedDek;
} catch (error) {
if (error instanceof TeamApiError && error.status === 403) {
throw new Error(`You don't have access to "${vault.name}", so you can't re-key it. Ask an existing member.`);
}
throw error;
}
const [{ secrets }, { grants }] = await Promise.all([client.listSecrets(vault.id), client.listGrants(vault.id)]);
const members: RekeyMember[] = grants.map((g) => ({
email: g.email,
publicKey: g.publicKey,
status: g.status,
hasAccess: g.hasAccess
}));
const plan = await planVaultRekey({
myWrappedDek,
identity: identity.keys,
secrets: secrets.map((s) => ({ name: s.name, nonce: s.nonce, ciphertext: s.ciphertext, fingerprint: s.fingerprint })),
members,
scope: options.scope
});
const summary: Record<string, unknown> = {
vault: vault.name,
secrets: plan.secrets.length,
keeps: plan.grants.map((g) => g.email),
revokes: plan.revoked,
skipped: plan.skipped,
applied: false
};
if (!options.approve) {
return summary;
}
const result = await client.rekeyVault(vault.id, {
grants: plan.grants,
secrets: plan.secrets,
revoke: plan.revoked
});
summary.applied = true;
summary.rekeyed = result.rekeyed;
return summary;
}
export async function credentialsRotateAction(
slug: string,
project: string | undefined,
env: string | undefined,
options: RotateOptions
): Promise<void> {
const { client, identity } = authedClient();
const targets = await resolveTargets(client, slug, project, env);
const results: Array<Record<string, unknown>> = [];
for (const vault of targets) {
results.push(await rotateOne(client, identity, vault, options));
}
if (!options.approve) {
const totalRevokes = results.reduce((n, r) => n + (r.revokes as string[]).length, 0);
console.error(
`Dry run: ${results.length} vault(s) would be re-keyed${totalRevokes ? `, revoking ${totalRevokes} grant(s)` : ""}. Values are unchanged by a re-key. Re-run with --approve to apply.`
);
} else {
console.error(`Re-keyed ${results.length} vault(s). Every previously issued vault key is now dead.`);
}
print(results, options.format);
}