Skip to content

Commit 9044180

Browse files
ralyodioclaude
andcommitted
fix(credentials): one vault per user, in the config dir
The credential store resolved its base directory against process.cwd(). Running the CLI from inside a git checkout wrote `.logicsrc/credentials` into that repo's working tree — a directory containing `vault/`, the one place raw credential values touch disk — untracked, unignored, and one `git add -A` from being committed. Two such directories were sitting in unrelated repos on the machine this was found on. A per-directory store is also the wrong shape for what the store is for. It is the record of what was rotated and what the prior values were, and a record that forks per project folder is several records that disagree. There is one user, one identity, one vault. Everything now hangs off a single logicsrcHome(): $LOGICSRC_HOME, else $XDG_CONFIG_HOME/logicsrc, else ~/.config/logicsrc. The credential store, the identity and the CLI config all read it rather than each deriving their own answer — three separate derivations is how the vault ended up somewhere the config never was. ~/.logicsrc is migrated rather than abandoned. It holds the X25519 secret key, and losing that loses access to every team vault the member was ever given, so it is moved on first use; a move that fails says so on stderr instead of leaving someone silently logged out with a key still on disk somewhere they were not told about. If the new directory already exists it wins and the old one is left untouched, because two directories both claiming to be the identity is how a login writes one and a read finds the other. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 87266bb commit 9044180

12 files changed

Lines changed: 223 additions & 27 deletions

File tree

docs/config.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
Logicsrc stores user config at:
44

55
```text
6-
$HOME/.logicsrc/config.json
6+
$HOME/.config/logicsrc/config.json
77
```
88

99
Read and write values with dot paths:

docs/credential-sharing.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ logicsrc credentials inspect --provider env --path .env
2121
logicsrc credentials diff --from env --from-path .env --to railway \
2222
--to-project <projectId> --to-config <environmentId>
2323

24-
# Build a plan (stored under .logicsrc/credentials), then dry-run, then apply
24+
# Build a plan (stored under ~/.config/logicsrc/credentials), then dry-run, then apply
2525
logicsrc credentials plan --from env --from-path .env --to doppler \
2626
--to-project <project> --to-config <config>
2727
logicsrc credentials sync --plan <planId> # dry-run (no writes)
@@ -40,8 +40,9 @@ Implementation notes:
4040
- `github-secrets` is write-only for values (GitHub never returns secret values), so
4141
it cannot be a sync source or a value-restoring rollback target. Secret writes are
4242
libsodium sealed-box encrypted against the repo/org/environment public key.
43-
- Rollback captures the target's prior values into a 0600 vault under `.logicsrc/`
44-
(gitignored) — the only place raw values touch disk. Plans, runs, and audit records
43+
- Rollback captures the target's prior values into a 0600 vault under
44+
`~/.config/logicsrc/` — outside any project, so there is nothing to gitignore
45+
and nothing lands in a repo. The only place raw values touch disk. Plans, runs, and audit records
4546
contain fingerprints only.
4647

4748
Credential Sharing is a LogicSRC OpenSpec for portable, auditable secret synchronization across local files and infrastructure providers. It is intended to replace closed, proprietary credential-sharing workflows with a provider-neutral contract.
@@ -195,7 +196,7 @@ relay for secret values**. It stores only:
195196
Plaintext secret values and the raw DEK never leave a member's machine. Granting a
196197
teammate access = an existing member unwraps the DEK with their private key and
197198
re-wraps (seals) it to the new member's public key. The private key lives only in
198-
`~/.logicsrc/identity.json` (mode 0600) and is never uploaded.
199+
`~/.config/logicsrc/identity.json` (mode 0600) and is never uploaded.
199200

200201
### CLI
201202

@@ -275,7 +276,7 @@ Safety properties, all enforced rather than documented:
275276
It talks to the hosted credentials app by default. Point it elsewhere (local dev,
276277
self-hosted) with `LOGICSRC_API=http://localhost:8080 logicsrc login` or
277278
`logicsrc login --api-url …`; the chosen origin is remembered in
278-
`~/.logicsrc/identity.json` once login succeeds.
279+
`~/.config/logicsrc/identity.json` once login succeeds.
279280

280281
Because `team` is a normal provider, the generic sync surface works too — e.g.
281282
`logicsrc credentials plan --from env --from-path .env --to team --to-project acme

packages/cli/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@logicsrc/cli",
3-
"version": "0.1.0",
3+
"version": "0.1.1",
44
"description": "LogicSRC OpenSpec CLI.",
55
"type": "module",
66
"main": "./dist/index.js",

packages/cli/src/config.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
22
import { dirname, join } from "node:path";
3-
import { homedir } from "node:os";
3+
import { logicsrcHome } from "@logicsrc/plugin-credential-sharing";
44

55
export type JsonObject = Record<string, unknown>;
66

@@ -21,8 +21,14 @@ export const defaultConfig: JsonObject = {
2121
}
2222
};
2323

24+
/**
25+
* The same one directory the identity and the vault use.
26+
*
27+
* Shared rather than re-derived: three copies of "where does logicsrc keep
28+
* things" is how the vault ended up somewhere the config never was.
29+
*/
2430
export function configPath() {
25-
return join(homedir(), ".logicsrc", "config.json");
31+
return join(logicsrcHome(), "config.json");
2632
}
2733

2834
export function readConfig() {

packages/cli/src/index.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { spawnSync } from "node:child_process";
33
import { readFileSync } from "node:fs";
44
import { evaluateAccountPolicy, scoreAccountActionRisk } from "@logicsrc/account-core";
55
import { Command } from "commander";
6-
import { createCredentialEngine, listCredentialProviders, type CredentialEndpoint } from "@logicsrc/plugin-credential-sharing";
6+
import { createCredentialEngine, listCredentialProviders, logicsrcHome, type CredentialEndpoint } from "@logicsrc/plugin-credential-sharing";
77
import { listEmailAccountProviders } from "@logicsrc/plugin-email-accounts";
88
import { discoverFeeds, listFeedProviders, probeSite, renderDiscoveryOutput, validateFeed, type FeedKind, type FeedOutputFormat } from "@logicsrc/plugin-feed-discovery";
99
import { listSocialAccountProviders } from "@logicsrc/plugin-social-accounts";
@@ -822,12 +822,14 @@ program
822822
process.exitCode = 1;
823823
return;
824824
}
825-
console.log(`Updated. Install root: ${installHome()} — config preserved at ~/.logicsrc`);
825+
console.log(`Updated. Install root: ${installHome()} — config preserved at ${logicsrcHome()}`);
826826
});
827827

828828
program.command("remove").alias("uninstall").option("--purge", "Remove config and auth tokens").description("Remove local LogicSRC CLI.").action((options) => {
829829
console.log("Removed LogicSRC CLI.");
830-
console.log(options.purge ? "Removed config and auth tokens from $HOME/.logicsrc." : "Preserved config at $HOME/.logicsrc. Run with --purge to remove config and auth tokens.");
830+
console.log(options.purge
831+
? `Removed config and auth tokens from ${logicsrcHome()}.`
832+
: `Preserved config at ${logicsrcHome()}. Run with --purge to remove config and auth tokens.`);
831833
});
832834

833835
function validateFile(kindArg: string, file: string) {

packages/cli/src/teams.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
defaultApiUrl,
1313
resolveApiUrl,
1414
createCredentialEngine,
15+
identityPath,
1516
unwrapVaultKey,
1617
wrapVaultKey,
1718
type CredentialEndpoint
@@ -280,7 +281,7 @@ export async function loginAction(options: { apiUrl?: string; token?: string; de
280281

281282
export async function logoutAction(): Promise<void> {
282283
await updateIdentity({ apiToken: undefined, email: undefined, userId: undefined });
283-
console.error("Logged out (local token cleared; revoke the key at /settings). Identity key retained — delete ~/.logicsrc/identity.json to remove it.");
284+
console.error(`Logged out (local token cleared; revoke the key at /settings). Identity key retained — delete ${identityPath()} to remove it.`);
284285
}
285286

286287
export async function whoamiAction(format: OutputFormat): Promise<void> {

packages/cli/src/update.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export type UpdateStatus = {
2626
latestCommit: string | null;
2727
};
2828

29-
/** Install root the installer uses (not the config dir, which is ~/.logicsrc). */
29+
/** Install root the installer uses (not the config dir, which is ~/.config/logicsrc). */
3030
export function installHome(env: NodeJS.ProcessEnv = process.env): string {
3131
return env.LOGICSRC_HOME || join(env.HOME || homedir(), ".logicsrc-cli");
3232
}

plugins/credential-sharing/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@logicsrc/plugin-credential-sharing",
3-
"version": "0.1.0",
3+
"version": "0.1.1",
44
"description": "LogicSRC Credential Sharing OpenSpec plugin: portable, auditable secret sync across .env, Doppler, Railway, and GitHub Secrets.",
55
"type": "module",
66
"main": "./dist/index.js",

plugins/credential-sharing/src/identity.ts

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
1-
import { mkdirSync, readFileSync, writeFileSync, existsSync, chmodSync } from "node:fs";
1+
import { mkdirSync, readFileSync, writeFileSync, existsSync, chmodSync, renameSync } from "node:fs";
22
import { homedir } from "node:os";
33
import { dirname, join, resolve } from "node:path";
44
import { generateIdentityKeyPair, publicKeyForSecret, type IdentityKeyPair } from "./crypto.js";
55

66
/**
77
* Local, machine-bound member identity for team credential sharing.
88
*
9-
* Stored at `$LOGICSRC_HOME/identity.json` (default `~/.logicsrc/identity.json`),
9+
* Stored at `$LOGICSRC_HOME/identity.json` (default `~/.config/logicsrc/identity.json`),
1010
* mode 0600 — it holds the member's X25519 SECRET key and the server API token.
1111
* The secret key never leaves this file; only the public key is uploaded.
1212
*/
@@ -25,13 +25,63 @@ export interface LocalIdentity {
2525
updatedAt: string;
2626
}
2727

28+
/**
29+
* The one logicsrc directory for this user, on this machine.
30+
*
31+
* `$LOGICSRC_HOME`, else `$XDG_CONFIG_HOME/logicsrc`, else
32+
* `~/.config/logicsrc`. Never anything derived from the working directory:
33+
* there is a single identity and a single vault per user, and a path that
34+
* moves when you `cd` gives you one of each per directory you happened to be
35+
* standing in — which is how a machine ends up with a `.logicsrc/` inside
36+
* unrelated git repos, holding a directory called `credentials/vault`.
37+
*
38+
* A previous install kept this at `~/.logicsrc`. That directory holds the
39+
* X25519 secret key, so it is moved rather than abandoned — losing it means
40+
* losing access to every team vault the member was ever given.
41+
*/
2842
export function logicsrcHome(): string {
2943
if (process.env.LOGICSRC_HOME) {
3044
return resolve(process.env.LOGICSRC_HOME);
3145
}
46+
const configHome = process.env.XDG_CONFIG_HOME
47+
? resolve(process.env.XDG_CONFIG_HOME)
48+
: join(homedir(), ".config");
49+
const home = join(configHome, "logicsrc");
50+
migrateLegacyHome(home);
51+
return home;
52+
}
53+
54+
/** Where this lived before the move, kept only to be migrated away from. */
55+
export function legacyLogicsrcHome(): string {
3256
return join(homedir(), ".logicsrc");
3357
}
3458

59+
/**
60+
* Move `~/.logicsrc` to the config dir, once, if the new one is not there yet.
61+
*
62+
* Deliberately a move and not a copy: two directories both claiming to be the
63+
* identity is the state where a login writes to one and a read finds the
64+
* other. If it cannot be moved the failure is named on stderr rather than
65+
* swallowed, because the alternative is a member silently logged out with a
66+
* secret key still sitting somewhere they were not told about.
67+
*/
68+
function migrateLegacyHome(target: string): void {
69+
const legacy = legacyLogicsrcHome();
70+
if (legacy === target || existsSync(target) || !existsSync(legacy)) {
71+
return;
72+
}
73+
try {
74+
mkdirSync(dirname(target), { recursive: true });
75+
renameSync(legacy, target);
76+
} catch (error) {
77+
const why = error instanceof Error ? error.message : String(error);
78+
process.emitWarning(
79+
`logicsrc: could not move ${legacy} to ${target} (${why}). ` +
80+
`Move it by hand — it holds your identity key.`
81+
);
82+
}
83+
}
84+
3585
export function identityPath(): string {
3686
return process.env.LOGICSRC_IDENTITY_FILE
3787
? resolve(process.env.LOGICSRC_IDENTITY_FILE)
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
// Where the identity and the vault live.
2+
//
3+
// These used to be three different answers. The identity was under
4+
// `~/.logicsrc`, the CLI config beside it, and the credential store resolved
5+
// against `process.cwd()` — so the vault was wherever you were standing when
6+
// you ran the command. Running the CLI inside a git checkout wrote a directory
7+
// literally named `credentials/vault` into that repo's working tree: untracked,
8+
// unignored, one `git add -A` from being published.
9+
//
10+
// There is one vault per user, per machine. That is what these pin.
11+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
12+
import { mkdtempSync, mkdirSync, writeFileSync, existsSync, readFileSync, rmSync } from "node:fs";
13+
import { tmpdir } from "node:os";
14+
import { join } from "node:path";
15+
16+
import { logicsrcHome, identityPath, legacyLogicsrcHome } from "./identity.js";
17+
import { defaultCredentialHome } from "./store.js";
18+
19+
const ENV_KEYS = ["LOGICSRC_HOME", "XDG_CONFIG_HOME", "HOME", "LOGICSRC_CREDENTIAL_HOME", "LOGICSRC_IDENTITY_FILE"] as const;
20+
21+
let saved: Record<string, string | undefined>;
22+
let sandbox: string;
23+
24+
beforeEach(() => {
25+
saved = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]]));
26+
sandbox = mkdtempSync(join(tmpdir(), "logicsrc-paths-"));
27+
for (const k of ENV_KEYS) delete process.env[k];
28+
process.env.HOME = sandbox;
29+
});
30+
31+
afterEach(() => {
32+
for (const [k, v] of Object.entries(saved)) {
33+
if (v === undefined) delete process.env[k];
34+
else process.env[k] = v;
35+
}
36+
rmSync(sandbox, { recursive: true, force: true });
37+
});
38+
39+
describe("logicsrc home", () => {
40+
it("defaults to ~/.config/logicsrc", () => {
41+
expect(logicsrcHome()).toBe(join(sandbox, ".config", "logicsrc"));
42+
});
43+
44+
it("honours XDG_CONFIG_HOME", () => {
45+
process.env.XDG_CONFIG_HOME = join(sandbox, "xdg");
46+
expect(logicsrcHome()).toBe(join(sandbox, "xdg", "logicsrc"));
47+
});
48+
49+
it("lets LOGICSRC_HOME override everything", () => {
50+
process.env.LOGICSRC_HOME = join(sandbox, "explicit");
51+
expect(logicsrcHome()).toBe(join(sandbox, "explicit"));
52+
});
53+
});
54+
55+
describe("the credential store", () => {
56+
it("never resolves against the working directory", () => {
57+
// The regression this exists for. Whatever the cwd is, the vault is not
58+
// under it — a `.logicsrc/` appearing inside a project is the bug.
59+
const home = defaultCredentialHome();
60+
expect(home).toBe(join(sandbox, ".config", "logicsrc", "credentials"));
61+
expect(home.startsWith(process.cwd())).toBe(false);
62+
});
63+
64+
it("is the same store no matter where the CLI is run from", () => {
65+
const before = defaultCredentialHome();
66+
const elsewhere = mkdtempSync(join(tmpdir(), "logicsrc-cwd-"));
67+
const original = process.cwd();
68+
try {
69+
process.chdir(elsewhere);
70+
expect(defaultCredentialHome()).toBe(before);
71+
} finally {
72+
process.chdir(original);
73+
rmSync(elsewhere, { recursive: true, force: true });
74+
}
75+
});
76+
77+
it("still takes an explicit LOGICSRC_CREDENTIAL_HOME", () => {
78+
process.env.LOGICSRC_CREDENTIAL_HOME = join(sandbox, "vol", "creds");
79+
expect(defaultCredentialHome()).toBe(join(sandbox, "vol", "creds"));
80+
});
81+
82+
it("sits beside the identity, under one home", () => {
83+
expect(defaultCredentialHome()).toBe(join(logicsrcHome(), "credentials"));
84+
expect(identityPath()).toBe(join(logicsrcHome(), "identity.json"));
85+
});
86+
});
87+
88+
describe("migrating off ~/.logicsrc", () => {
89+
it("moves the old directory, keeping the identity key", () => {
90+
// The secret key is the whole account: losing it loses every team vault
91+
// the member was ever given. So this is a move, not a fresh start.
92+
const legacy = legacyLogicsrcHome();
93+
mkdirSync(legacy, { recursive: true });
94+
writeFileSync(join(legacy, "identity.json"), '{"keys":{"secretKey":"kept"}}');
95+
96+
const home = logicsrcHome();
97+
expect(existsSync(legacy)).toBe(false);
98+
expect(JSON.parse(readFileSync(join(home, "identity.json"), "utf8")).keys.secretKey).toBe("kept");
99+
});
100+
101+
it("leaves the old directory alone once the new one exists", () => {
102+
// Two directories both claiming to be the identity is the state where a
103+
// login writes one and a read finds the other. Whatever is already at the
104+
// new path wins; the legacy one is not merged over it.
105+
const legacy = legacyLogicsrcHome();
106+
mkdirSync(legacy, { recursive: true });
107+
writeFileSync(join(legacy, "identity.json"), '{"keys":{"secretKey":"old"}}');
108+
const home = join(sandbox, ".config", "logicsrc");
109+
mkdirSync(home, { recursive: true });
110+
writeFileSync(join(home, "identity.json"), '{"keys":{"secretKey":"current"}}');
111+
112+
logicsrcHome();
113+
expect(JSON.parse(readFileSync(join(home, "identity.json"), "utf8")).keys.secretKey).toBe("current");
114+
expect(existsSync(legacy)).toBe(true);
115+
});
116+
117+
it("does nothing when there is no legacy directory", () => {
118+
const home = logicsrcHome();
119+
expect(existsSync(legacyLogicsrcHome())).toBe(false);
120+
expect(home).toBe(join(sandbox, ".config", "logicsrc"));
121+
});
122+
});

0 commit comments

Comments
 (0)