@stitchaxis/latchkey 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +48 -2
  2. package/dist/audit.d.ts +11 -0
  3. package/dist/audit.js +7 -0
  4. package/dist/cli.d.ts +2 -0
  5. package/dist/cli.js +239 -0
  6. package/dist/crypto.d.ts +18 -0
  7. package/dist/crypto.js +43 -0
  8. package/dist/crypto.test.d.ts +1 -0
  9. package/dist/crypto.test.js +21 -0
  10. package/dist/daemon.d.ts +2 -0
  11. package/dist/daemon.js +45 -0
  12. package/dist/index.d.ts +3 -0
  13. package/dist/index.js +3 -0
  14. package/dist/keychain.d.ts +8 -0
  15. package/dist/keychain.js +73 -0
  16. package/dist/licensing/access.d.ts +8 -0
  17. package/dist/licensing/access.js +35 -0
  18. package/dist/licensing/cache.d.ts +11 -0
  19. package/dist/licensing/cache.js +42 -0
  20. package/dist/licensing/enforce.d.ts +3 -0
  21. package/dist/licensing/enforce.js +20 -0
  22. package/dist/licensing/enforce.test.d.ts +1 -0
  23. package/dist/licensing/enforce.test.js +57 -0
  24. package/dist/licensing/registry.d.ts +5 -0
  25. package/dist/licensing/registry.js +59 -0
  26. package/dist/licensing/trial.d.ts +6 -0
  27. package/dist/licensing/trial.js +23 -0
  28. package/dist/lock.d.ts +6 -0
  29. package/dist/lock.js +55 -0
  30. package/dist/mcp-server.d.ts +2 -0
  31. package/dist/mcp-server.js +82 -0
  32. package/dist/paths.d.ts +13 -0
  33. package/dist/paths.js +15 -0
  34. package/dist/redact.d.ts +5 -0
  35. package/dist/redact.js +32 -0
  36. package/dist/redact.test.d.ts +1 -0
  37. package/dist/redact.test.js +12 -0
  38. package/dist/sandbox.d.ts +4 -0
  39. package/dist/sandbox.js +10 -0
  40. package/dist/scan.d.ts +6 -0
  41. package/dist/scan.js +50 -0
  42. package/dist/scope.d.ts +9 -0
  43. package/dist/scope.js +36 -0
  44. package/dist/scope.test.d.ts +1 -0
  45. package/dist/scope.test.js +15 -0
  46. package/dist/vault.d.ts +35 -0
  47. package/dist/vault.js +171 -0
  48. package/dist/vault.test.d.ts +1 -0
  49. package/dist/vault.test.js +32 -0
  50. package/package.json +38 -4
package/dist/vault.js ADDED
@@ -0,0 +1,171 @@
1
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
2
+ import { existsSync } from "node:fs";
3
+ import { CONFIG_FILE, LATCHKEY_DIR, VAULT_FILE, } from "./paths.js";
4
+ import { decryptSecret, encryptSecret, initSodium, } from "./crypto.js";
5
+ import { generateMasterKey, storeMasterKeyInKeychain, storePassphraseWrap, tryGetMasterKeyFromKeychain, unlockWithPassphrase, hasPassphraseWrap, } from "./keychain.js";
6
+ import { getCachedMasterKey, setCachedMasterKey, touchActivity, lockVault, isLocked, } from "./lock.js";
7
+ import { auditLog } from "./audit.js";
8
+ import { isKeyAllowed, loadProjectScope } from "./scope.js";
9
+ import { getEffectiveAccess } from "./licensing/access.js";
10
+ import { enforceBeforeSecretAccess, enforceBeforeVaultWrite, } from "./licensing/enforce.js";
11
+ async function readConfig() {
12
+ if (!existsSync(CONFIG_FILE))
13
+ return {};
14
+ const raw = await readFile(CONFIG_FILE, "utf8");
15
+ return JSON.parse(raw);
16
+ }
17
+ async function writeConfig(config) {
18
+ await mkdir(LATCHKEY_DIR, { recursive: true });
19
+ await writeFile(CONFIG_FILE, JSON.stringify(config, null, 2), "utf8");
20
+ }
21
+ export async function ensureVaultInitialized(options) {
22
+ await initSodium();
23
+ await mkdir(LATCHKEY_DIR, { recursive: true });
24
+ await getEffectiveAccess();
25
+ if (!existsSync(VAULT_FILE)) {
26
+ await enforceBeforeVaultWrite();
27
+ const masterKey = generateMasterKey();
28
+ if (options?.passphrase || options?.preferPassphrase) {
29
+ if (!options.passphrase) {
30
+ throw new Error("Passphrase required for passphrase-backed vault");
31
+ }
32
+ await storePassphraseWrap(masterKey, options.passphrase);
33
+ }
34
+ else {
35
+ await storeMasterKeyInKeychain(masterKey);
36
+ }
37
+ const doc = { version: 1, secrets: [] };
38
+ await writeFile(VAULT_FILE, JSON.stringify(doc, null, 2), "utf8");
39
+ await auditLog({ action: "vault_init" });
40
+ }
41
+ const config = await readConfig();
42
+ if (config.idleLockMinutes) {
43
+ const { configureIdleLock } = await import("./lock.js");
44
+ configureIdleLock(config.idleLockMinutes * 60 * 1000);
45
+ }
46
+ }
47
+ export async function unlockVault(passphrase) {
48
+ await initSodium();
49
+ const cached = getCachedMasterKey();
50
+ if (cached) {
51
+ touchActivity();
52
+ return cached;
53
+ }
54
+ let masterKey = await tryGetMasterKeyFromKeychain();
55
+ if (!masterKey && (await hasPassphraseWrap())) {
56
+ if (!passphrase) {
57
+ throw new Error("Vault is locked. Provide LATCHKEY_PASSPHRASE or run latchkey unlock (passphrase fallback).");
58
+ }
59
+ masterKey = await unlockWithPassphrase(passphrase);
60
+ }
61
+ if (!masterKey) {
62
+ throw new Error("No master key in OS keychain and no passphrase wrap found. Run latchkey init.");
63
+ }
64
+ setCachedMasterKey(masterKey);
65
+ await auditLog({ action: "unlock" });
66
+ return masterKey;
67
+ }
68
+ export async function loadVaultDoc() {
69
+ if (!existsSync(VAULT_FILE)) {
70
+ throw new Error("Vault not initialized. Run: latchkey init");
71
+ }
72
+ const raw = await readFile(VAULT_FILE, "utf8");
73
+ return JSON.parse(raw);
74
+ }
75
+ async function saveVaultDoc(doc) {
76
+ await writeFile(VAULT_FILE, JSON.stringify(doc, null, 2), "utf8");
77
+ }
78
+ export async function addSecret(name, value, passphrase) {
79
+ await getEffectiveAccess();
80
+ const masterKey = await unlockVault(passphrase);
81
+ const doc = await loadVaultDoc();
82
+ const now = new Date().toISOString();
83
+ const { ciphertext, nonce } = encryptSecret(value, masterKey);
84
+ const existing = doc.secrets.findIndex((s) => s.name === name);
85
+ const entry = {
86
+ name,
87
+ ciphertext,
88
+ nonce,
89
+ createdAt: existing >= 0 ? doc.secrets[existing].createdAt : now,
90
+ updatedAt: now,
91
+ };
92
+ if (existing >= 0) {
93
+ doc.secrets[existing] = entry;
94
+ }
95
+ else {
96
+ doc.secrets.push(entry);
97
+ }
98
+ await saveVaultDoc(doc);
99
+ touchActivity();
100
+ await auditLog({ action: "secret_add", key: name });
101
+ }
102
+ export async function getSecret(name, options) {
103
+ const cwd = options?.cwd ?? process.cwd();
104
+ await enforceBeforeSecretAccess(cwd);
105
+ const masterKey = await unlockVault(options?.passphrase);
106
+ const scope = options?.enforceScope !== false
107
+ ? await loadProjectScope(options?.cwd ?? process.cwd())
108
+ : null;
109
+ if (options?.enforceScope !== false && !isKeyAllowed(scope, name)) {
110
+ await auditLog({
111
+ action: "scope_denied",
112
+ key: name,
113
+ project: scope?.projectRoot,
114
+ });
115
+ throw new Error(`Key "${name}" is not allowed by ${scope ? ".latchkey.toml" : "scope policy"}`);
116
+ }
117
+ const doc = await loadVaultDoc();
118
+ const entry = doc.secrets.find((s) => s.name === name);
119
+ if (!entry) {
120
+ throw new Error(`Unknown secret: ${name}`);
121
+ }
122
+ const value = decryptSecret(entry.ciphertext, entry.nonce, masterKey);
123
+ touchActivity();
124
+ await auditLog({
125
+ action: "secret_get",
126
+ key: name,
127
+ project: scope?.projectRoot,
128
+ });
129
+ return value;
130
+ }
131
+ export async function listSecrets(passphrase) {
132
+ await getEffectiveAccess();
133
+ await unlockVault(passphrase);
134
+ const doc = await loadVaultDoc();
135
+ touchActivity();
136
+ await auditLog({ action: "secret_list" });
137
+ return doc.secrets.map((s) => s.name).sort();
138
+ }
139
+ export async function removeSecret(name, passphrase) {
140
+ await unlockVault(passphrase);
141
+ const doc = await loadVaultDoc();
142
+ const next = doc.secrets.filter((s) => s.name !== name);
143
+ if (next.length === doc.secrets.length) {
144
+ throw new Error(`Unknown secret: ${name}`);
145
+ }
146
+ doc.secrets = next;
147
+ await saveVaultDoc(doc);
148
+ touchActivity();
149
+ await auditLog({ action: "secret_remove", key: name });
150
+ }
151
+ export async function buildEnvForRun(keyNames, options) {
152
+ const env = {};
153
+ for (const name of keyNames) {
154
+ env[name] = await getSecret(name, {
155
+ passphrase: options?.passphrase,
156
+ cwd: options?.cwd,
157
+ enforceScope: true,
158
+ });
159
+ }
160
+ await auditLog({
161
+ action: "run_inject",
162
+ command: keyNames.join(","),
163
+ project: options?.cwd,
164
+ });
165
+ return env;
166
+ }
167
+ export function lock() {
168
+ lockVault();
169
+ void auditLog({ action: "lock" });
170
+ }
171
+ export { isLocked };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,32 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { mkdtemp, rm } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ describe("vault", () => {
6
+ let prevHome;
7
+ let tempHome;
8
+ beforeEach(async () => {
9
+ prevHome = process.env.LATCHKEY_HOME;
10
+ tempHome = await mkdtemp(join(tmpdir(), "latchkey-test-"));
11
+ process.env.LATCHKEY_HOME = tempHome;
12
+ });
13
+ afterEach(async () => {
14
+ process.env.LATCHKEY_HOME = prevHome;
15
+ await rm(tempHome, { recursive: true, force: true });
16
+ });
17
+ it("stores and retrieves secrets with passphrase wrap", async () => {
18
+ const { ensureVaultInitialized, addSecret, getSecret, listSecrets } = await import("./vault.js");
19
+ await ensureVaultInitialized({
20
+ passphrase: "test-passphrase-123",
21
+ preferPassphrase: true,
22
+ });
23
+ await addSecret("OPENAI_API_KEY", "sk-test-value", "test-passphrase-123");
24
+ const names = await listSecrets("test-passphrase-123");
25
+ expect(names).toContain("OPENAI_API_KEY");
26
+ const value = await getSecret("OPENAI_API_KEY", {
27
+ passphrase: "test-passphrase-123",
28
+ enforceScope: false,
29
+ });
30
+ expect(value).toBe("sk-test-value");
31
+ }, 20000);
32
+ });
package/package.json CHANGED
@@ -1,6 +1,40 @@
1
1
  {
2
2
  "name": "@stitchaxis/latchkey",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Local-first encrypted vault for AI API keys",
5
+ "type": "module",
6
+ "bin": {
7
+ "latchkey": "./dist/cli.js"
8
+ },
9
+ "main": "./dist/index.js",
10
+ "types": "./dist/index.d.ts",
11
+ "files": [
12
+ "dist"
13
+ ],
14
+ "publishConfig": {
15
+ "access": "public"
16
+ },
17
+ "scripts": {
18
+ "build": "node ../../node_modules/typescript/bin/tsc",
19
+ "test": "node ../../node_modules/vitest/vitest.mjs run",
20
+ "test:watch": "node ../../node_modules/vitest/vitest.mjs",
21
+ "daemon": "node dist/daemon.js",
22
+ "mcp": "node dist/mcp-server.js",
23
+ "prepublishOnly": "npm run build"
24
+ },
25
+ "dependencies": {
26
+ "@stitchaxis/license": "0.1.0",
27
+ "@modelcontextprotocol/sdk": "^1.12.1",
28
+ "commander": "^13.1.0",
29
+ "keytar": "^7.9.0",
30
+ "libsodium-wrappers-sumo": "^0.8.4",
31
+ "toml": "^3.0.0",
32
+ "zod": "^3.24.2"
33
+ },
34
+ "devDependencies": {
35
+ "@types/libsodium-wrappers": "^0.7.14",
36
+ "@types/node": "^22.13.10",
37
+ "typescript": "^5.8.2",
38
+ "vitest": "^3.0.9"
39
+ }
40
+ }