@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/README.md CHANGED
@@ -1,3 +1,49 @@
1
- # Temporary Holding Version
1
+ # Latchkey CLI
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Local-first encrypted vault for AI API keys. No network calls by default.
4
+
5
+ ## Quickstart (after `npm install -g @stitchaxis/latchkey`)
6
+
7
+ ```bash
8
+ latchkey login <licence-key>
9
+ latchkey init # or: latchkey init --passphrase
10
+ latchkey add OPENAI_API_KEY # value from LATCHKEY_VALUE
11
+ latchkey list
12
+ latchkey run -- node agent.js # needs .latchkey.toml; Approve with y + Enter
13
+ ```
14
+
15
+ ## Windows PowerShell (first success)
16
+
17
+ Use `$env:` — not cmd `set`. Full walkthrough: [`docs/FIRST_RUN_WINDOWS.md`](../../docs/FIRST_RUN_WINDOWS.md).
18
+
19
+ ```powershell
20
+ # From monorepo before npm publish:
21
+ .\lk.cmd login <licence-key>
22
+ .\lk.cmd init --passphrase
23
+ $env:LATCHKEY_PASSPHRASE = "your-vault-password"
24
+ $env:LATCHKEY_VALUE = "sk-test-fake-not-real"
25
+ .\lk.cmd add MY_API_KEY
26
+
27
+ # .latchkey.toml → [scope] allowed = ["MY_API_KEY"]
28
+ .\lk.cmd run -- node -e "console.log('got=', process.env.MY_API_KEY)"
29
+ # Approve? [y/N] → type y → Enter
30
+ # expect: got= sk-test-fake-not-real
31
+ ```
32
+
33
+ If an IDE terminal won’t accept keys at Approve: `.\lk.cmd run --yes -- …`
34
+
35
+ ## Project scope (`.latchkey.toml`)
36
+
37
+ ```toml
38
+ [scope]
39
+ allowed = ["OPENAI_API_KEY", "ANTHROPIC_API_KEY"]
40
+ ```
41
+
42
+ ## Daemon & MCP
43
+
44
+ ```bash
45
+ node packages/cli/dist/daemon.js # Unix socket at ~/.latchkey/daemon.sock
46
+ LATCHKEY_MCP_APPROVE=1 node packages/cli/dist/mcp-server.js
47
+ ```
48
+
49
+ See [SECURITY.md](./SECURITY.md) for the threat model.
@@ -0,0 +1,11 @@
1
+ export type AuditAction = "vault_init" | "secret_add" | "secret_get" | "secret_list" | "secret_remove" | "run_inject" | "scope_denied" | "unlock" | "lock" | "mcp_approve" | "mcp_deny" | "mcp_error";
2
+ export interface AuditEntry {
3
+ ts: string;
4
+ action: AuditAction;
5
+ key?: string;
6
+ name?: string;
7
+ project?: string;
8
+ command?: string;
9
+ detail?: string;
10
+ }
11
+ export declare function auditLog(entry: Omit<AuditEntry, "ts">): Promise<void>;
package/dist/audit.js ADDED
@@ -0,0 +1,7 @@
1
+ import { appendFile, mkdir } from "node:fs/promises";
2
+ import { AUDIT_FILE, LATCHKEY_DIR } from "./paths.js";
3
+ export async function auditLog(entry) {
4
+ await mkdir(LATCHKEY_DIR, { recursive: true });
5
+ const line = { ts: new Date().toISOString(), ...entry };
6
+ await appendFile(AUDIT_FILE, JSON.stringify(line) + "\n", "utf8");
7
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,239 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from "node:child_process";
3
+ import { createInterface } from "node:readline";
4
+ import { stdin as input, stdout as output } from "node:process";
5
+ import { Command } from "commander";
6
+ import { addSecret, buildEnvForRun, ensureVaultInitialized, getSecret, listSecrets, lock, removeSecret, } from "./vault.js";
7
+ import { loadProjectScope } from "./scope.js";
8
+ import { redactSecrets } from "./redact.js";
9
+ import { scanRepository } from "./scan.js";
10
+ import { loginWithToken } from "./licensing/cache.js";
11
+ import { enforceOnLogin } from "./licensing/enforce.js";
12
+ import { getEffectiveAccess } from "./licensing/access.js";
13
+ import { sandboxEnvFlag, sandboxPreviewNotice } from "./sandbox.js";
14
+ function passphraseFromEnv() {
15
+ return process.env.LATCHKEY_PASSPHRASE;
16
+ }
17
+ async function readPassphraseInteractive() {
18
+ const rl = createInterface({ input, output });
19
+ return new Promise((resolve) => {
20
+ rl.question("Passphrase: ", (answer) => {
21
+ rl.close();
22
+ resolve(answer);
23
+ });
24
+ });
25
+ }
26
+ /** Returns true if user types y / yes / approve. */
27
+ async function promptApprove(keys) {
28
+ console.log("");
29
+ console.log("Latchkey — inject secrets into this command?");
30
+ console.log(` names: ${keys.join(", ")}`);
31
+ console.log(" Type y then Enter to Approve, or n then Enter to Deny.");
32
+ const rl = createInterface({ input, output });
33
+ const answer = await new Promise((resolve) => {
34
+ rl.question("Approve? [y/N] ", (a) => {
35
+ rl.close();
36
+ resolve(a.trim().toLowerCase());
37
+ });
38
+ });
39
+ return answer === "y" || answer === "yes" || answer === "approve";
40
+ }
41
+ const program = new Command();
42
+ program
43
+ .name("latchkey")
44
+ .description("Local-first encrypted vault for AI API keys")
45
+ .version("0.1.0");
46
+ program
47
+ .command("login <licenceKey>")
48
+ .description("Activate a signed licence (cached offline for 7 days)")
49
+ .action(async (licenceKey) => {
50
+ try {
51
+ await loginWithToken(licenceKey.trim());
52
+ await enforceOnLogin();
53
+ const { payload } = await getEffectiveAccess();
54
+ console.log(`Licence active: ${payload.tier} (${payload.email})`);
55
+ }
56
+ catch (e) {
57
+ console.error(redactSecrets(String(e)));
58
+ process.exitCode = 1;
59
+ }
60
+ });
61
+ program
62
+ .command("init")
63
+ .description("Initialize vault (master key in OS keychain, optional passphrase fallback)")
64
+ .option("--passphrase", "Use passphrase wrap instead of OS keychain")
65
+ .action(async (opts) => {
66
+ try {
67
+ let pass;
68
+ if (opts.passphrase) {
69
+ pass = await readPassphraseInteractive();
70
+ }
71
+ await ensureVaultInitialized({
72
+ passphrase: pass,
73
+ preferPassphrase: opts.passphrase,
74
+ });
75
+ console.log("Vault initialized at ~/.latchkey");
76
+ }
77
+ catch (e) {
78
+ console.error(redactSecrets(String(e)));
79
+ process.exitCode = 1;
80
+ }
81
+ });
82
+ program
83
+ .command("add <name>")
84
+ .description("Store a secret (value from LATCHKEY_VALUE or prompt)")
85
+ .option("--value <value>", "Secret value (avoid shell history)")
86
+ .action(async (name, opts) => {
87
+ try {
88
+ let value = opts.value ?? process.env.LATCHKEY_VALUE;
89
+ if (!value) {
90
+ value = await readPassphraseInteractive();
91
+ }
92
+ await addSecret(name, value, passphraseFromEnv());
93
+ console.log(`Stored ${name}`);
94
+ }
95
+ catch (e) {
96
+ console.error(redactSecrets(String(e)));
97
+ process.exitCode = 1;
98
+ }
99
+ });
100
+ program
101
+ .command("get <name>")
102
+ .description("Print secret to stdout (respects .latchkey.toml scope)")
103
+ .action(async (name) => {
104
+ try {
105
+ const value = await getSecret(name, { passphrase: passphraseFromEnv() });
106
+ process.stdout.write(value);
107
+ }
108
+ catch (e) {
109
+ console.error(redactSecrets(String(e)));
110
+ process.exitCode = 1;
111
+ }
112
+ });
113
+ program
114
+ .command("list")
115
+ .description("List secret names (never values)")
116
+ .action(async () => {
117
+ try {
118
+ const names = await listSecrets(passphraseFromEnv());
119
+ for (const n of names) {
120
+ console.log(n);
121
+ }
122
+ }
123
+ catch (e) {
124
+ console.error(redactSecrets(String(e)));
125
+ process.exitCode = 1;
126
+ }
127
+ });
128
+ program
129
+ .command("rm <name>")
130
+ .description("Remove a secret")
131
+ .action(async (name) => {
132
+ try {
133
+ await removeSecret(name, passphraseFromEnv());
134
+ console.log(`Removed ${name}`);
135
+ }
136
+ catch (e) {
137
+ console.error(redactSecrets(String(e)));
138
+ process.exitCode = 1;
139
+ }
140
+ });
141
+ program
142
+ .command("scan")
143
+ .description("Local leak scan for exposed key-shaped strings (runs offline)")
144
+ .option("--json", "JSON output")
145
+ .action(async (opts) => {
146
+ try {
147
+ const { payload } = await getEffectiveAccess();
148
+ if (!payload.leakScan) {
149
+ throw new Error("Leak scan requires a leak-scan licence. Purchase at https://modexventures.com/latchkey/pricing");
150
+ }
151
+ const findings = await scanRepository(process.cwd());
152
+ if (opts.json) {
153
+ console.log(JSON.stringify({ findings }, null, 2));
154
+ }
155
+ else if (findings.length === 0) {
156
+ console.log("No key-shaped strings found.");
157
+ }
158
+ else {
159
+ for (const f of findings) {
160
+ console.log(`${f.file}:${f.line} ${f.preview}`);
161
+ }
162
+ }
163
+ }
164
+ catch (e) {
165
+ console.error(redactSecrets(String(e)));
166
+ process.exitCode = 1;
167
+ }
168
+ });
169
+ program
170
+ .command("lock")
171
+ .description("Clear in-memory master key (auto-lock also applies after idle)")
172
+ .action(() => {
173
+ lock();
174
+ console.log("Locked");
175
+ });
176
+ program
177
+ .command("run")
178
+ .description("Run a command with scoped secrets injected into child env only (Approve: y/Enter; or --yes)")
179
+ .allowUnknownOption(true)
180
+ .allowExcessArguments(true)
181
+ .action(async () => {
182
+ const argv = process.argv;
183
+ const runIdx = argv.indexOf("run");
184
+ const sepIdx = argv.indexOf("--", runIdx);
185
+ if (sepIdx < 0 || sepIdx === argv.length - 1) {
186
+ console.error("Usage: latchkey run [--yes|-y] [--sandbox] -- <command> [args...]");
187
+ process.exitCode = 1;
188
+ return;
189
+ }
190
+ const between = argv.slice(runIdx + 1, sepIdx);
191
+ const sandbox = between.includes("--sandbox");
192
+ const autoYes = between.includes("--yes") ||
193
+ between.includes("-y") ||
194
+ process.env.LATCHKEY_APPROVE === "1" ||
195
+ process.env.LATCHKEY_APPROVE === "yes";
196
+ const cmdParts = argv.slice(sepIdx + 1);
197
+ const cmd = cmdParts[0];
198
+ const args = cmdParts.slice(1);
199
+ if (sandbox) {
200
+ sandboxPreviewNotice();
201
+ }
202
+ try {
203
+ const scope = await loadProjectScope(process.cwd());
204
+ const keys = scope?.allowed ?? [];
205
+ if (keys.length === 0) {
206
+ console.error("No keys to inject. Add a .latchkey.toml with scope.allowed = [\"OPENAI_API_KEY\", ...]");
207
+ process.exitCode = 1;
208
+ return;
209
+ }
210
+ const ok = autoYes
211
+ ? (console.log(`Approved (--yes): ${keys.join(", ")}`), true)
212
+ : await promptApprove(keys);
213
+ if (!ok) {
214
+ console.log("Denied — no secrets injected.");
215
+ process.exitCode = 1;
216
+ return;
217
+ }
218
+ const injected = await buildEnvForRun(keys, {
219
+ passphrase: passphraseFromEnv(),
220
+ cwd: process.cwd(),
221
+ });
222
+ const child = spawn(cmd, args, {
223
+ stdio: "inherit",
224
+ env: {
225
+ ...process.env,
226
+ ...injected,
227
+ ...(sandbox ? sandboxEnvFlag() : {}),
228
+ },
229
+ });
230
+ child.on("exit", (code) => {
231
+ process.exitCode = code ?? 0;
232
+ });
233
+ }
234
+ catch (e) {
235
+ console.error(redactSecrets(String(e)));
236
+ process.exitCode = 1;
237
+ }
238
+ });
239
+ program.parse();
@@ -0,0 +1,18 @@
1
+ export declare const MASTER_KEY_BYTES = 32;
2
+ export declare const SALT_BYTES = 16;
3
+ export declare function initSodium(): Promise<void>;
4
+ export declare function randomBytes(n: number): Uint8Array;
5
+ export declare function toB64(data: Uint8Array): string;
6
+ export declare function fromB64(s: string): Uint8Array;
7
+ /** Argon2id key derivation for passphrase fallback (libsodium crypto_pwhash). */
8
+ export declare function deriveKeyFromPassphrase(passphrase: string, salt: Uint8Array): Uint8Array;
9
+ export declare function encryptSecret(plaintext: string, key: Uint8Array): {
10
+ ciphertext: string;
11
+ nonce: string;
12
+ };
13
+ export declare function decryptSecret(ciphertext: string, nonce: string, key: Uint8Array): string;
14
+ export declare function wrapMasterKey(masterKey: Uint8Array, wrappingKey: Uint8Array): {
15
+ ciphertext: string;
16
+ nonce: string;
17
+ };
18
+ export declare function unwrapMasterKey(ciphertext: string, nonce: string, wrappingKey: Uint8Array): Uint8Array;
package/dist/crypto.js ADDED
@@ -0,0 +1,43 @@
1
+ import sodium from "libsodium-wrappers-sumo";
2
+ export const MASTER_KEY_BYTES = 32;
3
+ export const SALT_BYTES = 16;
4
+ export async function initSodium() {
5
+ await sodium.ready;
6
+ }
7
+ export function randomBytes(n) {
8
+ return sodium.randombytes_buf(n);
9
+ }
10
+ export function toB64(data) {
11
+ return sodium.to_base64(data, sodium.base64_variants.ORIGINAL);
12
+ }
13
+ export function fromB64(s) {
14
+ return sodium.from_base64(s, sodium.base64_variants.ORIGINAL);
15
+ }
16
+ /** Argon2id key derivation for passphrase fallback (libsodium crypto_pwhash). */
17
+ export function deriveKeyFromPassphrase(passphrase, salt) {
18
+ return sodium.crypto_pwhash(MASTER_KEY_BYTES, passphrase, salt, sodium.crypto_pwhash_OPSLIMIT_INTERACTIVE, sodium.crypto_pwhash_MEMLIMIT_INTERACTIVE, sodium.crypto_pwhash_ALG_ARGON2ID13);
19
+ }
20
+ export function encryptSecret(plaintext, key) {
21
+ const nonce = randomBytes(sodium.crypto_secretbox_NONCEBYTES);
22
+ const message = sodium.from_string(plaintext);
23
+ const boxed = sodium.crypto_secretbox_easy(message, nonce, key);
24
+ return {
25
+ ciphertext: toB64(boxed),
26
+ nonce: toB64(nonce),
27
+ };
28
+ }
29
+ export function decryptSecret(ciphertext, nonce, key) {
30
+ const boxed = fromB64(ciphertext);
31
+ const nonceBytes = fromB64(nonce);
32
+ const opened = sodium.crypto_secretbox_open_easy(boxed, nonceBytes, key);
33
+ if (!opened) {
34
+ throw new Error("Decryption failed (wrong key or corrupted data)");
35
+ }
36
+ return sodium.to_string(opened);
37
+ }
38
+ export function wrapMasterKey(masterKey, wrappingKey) {
39
+ return encryptSecret(toB64(masterKey), wrappingKey);
40
+ }
41
+ export function unwrapMasterKey(ciphertext, nonce, wrappingKey) {
42
+ return fromB64(decryptSecret(ciphertext, nonce, wrappingKey));
43
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,21 @@
1
+ import { describe, it, expect, beforeAll } from "vitest";
2
+ import { initSodium, randomBytes, encryptSecret, decryptSecret, deriveKeyFromPassphrase, wrapMasterKey, unwrapMasterKey, } from "./crypto.js";
3
+ beforeAll(async () => {
4
+ await initSodium();
5
+ });
6
+ describe("crypto", () => {
7
+ it("round-trips secretbox encryption", () => {
8
+ const key = randomBytes(32);
9
+ const plain = "sk-test-abcdefghijklmnopqrstuvwxyz1234567890";
10
+ const { ciphertext, nonce } = encryptSecret(plain, key);
11
+ expect(decryptSecret(ciphertext, nonce, key)).toBe(plain);
12
+ });
13
+ it("wraps master key with passphrase-derived key", () => {
14
+ const master = randomBytes(32);
15
+ const salt = randomBytes(16);
16
+ const wrapping = deriveKeyFromPassphrase("correct horse battery staple", salt);
17
+ const wrapped = wrapMasterKey(master, wrapping);
18
+ const unwrapped = unwrapMasterKey(wrapped.ciphertext, wrapped.nonce, wrapping);
19
+ expect(Buffer.from(unwrapped).equals(Buffer.from(master))).toBe(true);
20
+ });
21
+ });
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/daemon.js ADDED
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Local daemon: Unix socket IPC for lock status and idle heartbeat.
4
+ * No network listeners; no telemetry.
5
+ */
6
+ import { createServer } from "node:net";
7
+ import { mkdir } from "node:fs/promises";
8
+ import { join } from "node:path";
9
+ import { LATCHKEY_DIR } from "./paths.js";
10
+ import { isLocked, touchActivity } from "./lock.js";
11
+ import { redactSecrets } from "./redact.js";
12
+ const SOCKET_PATH = process.env.LATCHKEY_DAEMON_SOCKET ?? join(LATCHKEY_DIR, "daemon.sock");
13
+ async function main() {
14
+ await mkdir(LATCHKEY_DIR, { recursive: true });
15
+ try {
16
+ const { unlink } = await import("node:fs/promises");
17
+ await unlink(SOCKET_PATH).catch(() => { });
18
+ }
19
+ catch {
20
+ /* ignore */
21
+ }
22
+ const server = createServer((socket) => {
23
+ socket.on("data", (buf) => {
24
+ const msg = buf.toString("utf8").trim();
25
+ if (msg === "ping") {
26
+ touchActivity();
27
+ socket.write(JSON.stringify({ ok: true, locked: isLocked() }) + "\n");
28
+ }
29
+ else if (msg === "status") {
30
+ socket.write(JSON.stringify({ locked: isLocked() }) + "\n");
31
+ }
32
+ else {
33
+ socket.write(JSON.stringify({ error: "unknown_command" }) + "\n");
34
+ }
35
+ });
36
+ });
37
+ server.listen(SOCKET_PATH, () => {
38
+ process.stderr.write(`latchkey daemon listening on ${SOCKET_PATH}\n`);
39
+ });
40
+ server.on("error", (err) => {
41
+ process.stderr.write(redactSecrets(`daemon error: ${err}\n`));
42
+ process.exit(1);
43
+ });
44
+ }
45
+ main();
@@ -0,0 +1,3 @@
1
+ export { redactSecrets, redactValue } from "./redact.js";
2
+ export { isKeyAllowed, loadProjectScope } from "./scope.js";
3
+ export { addSecret, buildEnvForRun, ensureVaultInitialized, getSecret, listSecrets, lock, removeSecret, } from "./vault.js";
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { redactSecrets, redactValue } from "./redact.js";
2
+ export { isKeyAllowed, loadProjectScope } from "./scope.js";
3
+ export { addSecret, buildEnvForRun, ensureVaultInitialized, getSecret, listSecrets, lock, removeSecret, } from "./vault.js";
@@ -0,0 +1,8 @@
1
+ export type MasterKeySource = "keychain" | "passphrase";
2
+ export declare function tryGetMasterKeyFromKeychain(): Promise<Uint8Array | null>;
3
+ export declare function storeMasterKeyInKeychain(masterKey: Uint8Array): Promise<void>;
4
+ export declare function clearKeychainMasterKey(): Promise<void>;
5
+ export declare function hasPassphraseWrap(): Promise<boolean>;
6
+ export declare function storePassphraseWrap(masterKey: Uint8Array, passphrase: string): Promise<void>;
7
+ export declare function unlockWithPassphrase(passphrase: string): Promise<Uint8Array>;
8
+ export declare function generateMasterKey(): Uint8Array;
@@ -0,0 +1,73 @@
1
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
2
+ import { existsSync } from "node:fs";
3
+ import { KEYCHAIN_ACCOUNT, KEYCHAIN_SERVICE, PASSPHRASE_WRAP_FILE, LATCHKEY_DIR, } from "./paths.js";
4
+ import { deriveKeyFromPassphrase, fromB64, initSodium, randomBytes, toB64, unwrapMasterKey, wrapMasterKey, MASTER_KEY_BYTES, } from "./crypto.js";
5
+ async function loadKeytar() {
6
+ try {
7
+ const mod = await import("keytar");
8
+ return (mod.default ?? mod);
9
+ }
10
+ catch {
11
+ return null;
12
+ }
13
+ }
14
+ export async function tryGetMasterKeyFromKeychain() {
15
+ try {
16
+ const keytar = await loadKeytar();
17
+ if (!keytar)
18
+ return null;
19
+ const b64 = await keytar.getPassword(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT);
20
+ if (!b64)
21
+ return null;
22
+ return fromB64(b64);
23
+ }
24
+ catch {
25
+ return null;
26
+ }
27
+ }
28
+ export async function storeMasterKeyInKeychain(masterKey) {
29
+ const keytar = await loadKeytar();
30
+ if (!keytar) {
31
+ throw new Error("OS keychain (keytar) is unavailable. Re-run with --passphrase or rebuild native modules.");
32
+ }
33
+ await keytar.setPassword(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT, toB64(masterKey));
34
+ }
35
+ export async function clearKeychainMasterKey() {
36
+ try {
37
+ const keytar = await loadKeytar();
38
+ if (!keytar)
39
+ return;
40
+ await keytar.deletePassword(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT);
41
+ }
42
+ catch {
43
+ /* ignore */
44
+ }
45
+ }
46
+ export async function hasPassphraseWrap() {
47
+ return existsSync(PASSPHRASE_WRAP_FILE);
48
+ }
49
+ export async function storePassphraseWrap(masterKey, passphrase) {
50
+ await initSodium();
51
+ await mkdir(LATCHKEY_DIR, { recursive: true });
52
+ const salt = randomBytes(16);
53
+ const wrappingKey = deriveKeyFromPassphrase(passphrase, salt);
54
+ const wrapped = wrapMasterKey(masterKey, wrappingKey);
55
+ const doc = {
56
+ version: 1,
57
+ salt: toB64(salt),
58
+ ciphertext: wrapped.ciphertext,
59
+ nonce: wrapped.nonce,
60
+ };
61
+ await writeFile(PASSPHRASE_WRAP_FILE, JSON.stringify(doc, null, 2), "utf8");
62
+ }
63
+ export async function unlockWithPassphrase(passphrase) {
64
+ await initSodium();
65
+ const raw = await readFile(PASSPHRASE_WRAP_FILE, "utf8");
66
+ const doc = JSON.parse(raw);
67
+ const salt = fromB64(doc.salt);
68
+ const wrappingKey = deriveKeyFromPassphrase(passphrase, salt);
69
+ return unwrapMasterKey(doc.ciphertext, doc.nonce, wrappingKey);
70
+ }
71
+ export function generateMasterKey() {
72
+ return randomBytes(MASTER_KEY_BYTES);
73
+ }
@@ -0,0 +1,8 @@
1
+ import type { LicensePayload } from "@stitchaxis/license";
2
+ export interface EffectiveAccess {
3
+ source: "license" | "trial";
4
+ payload: LicensePayload;
5
+ daysRemaining?: number;
6
+ }
7
+ export declare function getEffectiveAccess(): Promise<EffectiveAccess>;
8
+ export declare function requireHardenedApproval(payload: LicensePayload): void;
@@ -0,0 +1,35 @@
1
+ import { TIER_LIMITS } from "@stitchaxis/license";
2
+ import { getCachedLicensePayload } from "./cache.js";
3
+ import { isTrialActive, trialDaysRemaining } from "./trial.js";
4
+ export async function getEffectiveAccess() {
5
+ const licensed = await getCachedLicensePayload();
6
+ if (licensed) {
7
+ return { source: "license", payload: licensed };
8
+ }
9
+ if (await isTrialActive()) {
10
+ const days = await trialDaysRemaining();
11
+ const trialPayload = {
12
+ v: 1,
13
+ tier: "solo",
14
+ email: "trial@local",
15
+ devices: TIER_LIMITS.solo.devices,
16
+ vaults: TIER_LIMITS.solo.vaults,
17
+ projects: TIER_LIMITS.solo.projects,
18
+ hardenedPolicy: false,
19
+ prioritySupport: false,
20
+ leakScan: false,
21
+ iat: 0,
22
+ exp: Math.floor(Date.now() / 1000) + days * 86400,
23
+ jti: "trial",
24
+ };
25
+ return { source: "trial", payload: trialPayload, daysRemaining: days };
26
+ }
27
+ throw new Error("Latchkey trial ended. Subscribe at https://modexventures.com/latchkey/pricing then run: latchkey login <licence-key>");
28
+ }
29
+ export function requireHardenedApproval(payload) {
30
+ if (!payload.hardenedPolicy)
31
+ return;
32
+ if (process.env.LATCHKEY_APPROVE_ACCESS !== "1") {
33
+ throw new Error("Executive hardened policy: set LATCHKEY_APPROVE_ACCESS=1 in this shell after you approve this secret access.");
34
+ }
35
+ }
@@ -0,0 +1,11 @@
1
+ import { type LicensePayload } from "@stitchaxis/license";
2
+ export interface LicenseCache {
3
+ token: string;
4
+ cachedAt: string;
5
+ offlineValidUntil: string;
6
+ payload: LicensePayload;
7
+ }
8
+ export declare function saveLicenseCache(token: string, payload: LicensePayload): Promise<void>;
9
+ export declare function readLicenseCache(): Promise<LicenseCache | null>;
10
+ export declare function getCachedLicensePayload(): Promise<LicensePayload | null>;
11
+ export declare function loginWithToken(token: string): Promise<LicensePayload>;
@@ -0,0 +1,42 @@
1
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
2
+ import { existsSync } from "node:fs";
3
+ import { verifyAndDecodeLicense } from "@stitchaxis/license";
4
+ import { LICENSE_CACHE_FILE, LATCHKEY_DIR } from "../paths.js";
5
+ const OFFLINE_CACHE_DAYS = 7;
6
+ export async function saveLicenseCache(token, payload) {
7
+ await mkdir(LATCHKEY_DIR, { recursive: true });
8
+ const now = new Date();
9
+ const offlineEnd = new Date(now.getTime() + OFFLINE_CACHE_DAYS * 24 * 60 * 60 * 1000);
10
+ const cache = {
11
+ token,
12
+ cachedAt: now.toISOString(),
13
+ offlineValidUntil: offlineEnd.toISOString(),
14
+ payload,
15
+ };
16
+ await writeFile(LICENSE_CACHE_FILE, JSON.stringify(cache, null, 2), "utf8");
17
+ }
18
+ export async function readLicenseCache() {
19
+ if (!existsSync(LICENSE_CACHE_FILE))
20
+ return null;
21
+ const raw = await readFile(LICENSE_CACHE_FILE, "utf8");
22
+ return JSON.parse(raw);
23
+ }
24
+ export async function getCachedLicensePayload() {
25
+ const cache = await readLicenseCache();
26
+ if (!cache)
27
+ return null;
28
+ if (new Date(cache.offlineValidUntil).getTime() < Date.now()) {
29
+ return null;
30
+ }
31
+ try {
32
+ return verifyAndDecodeLicense(cache.token);
33
+ }
34
+ catch {
35
+ return cache.payload;
36
+ }
37
+ }
38
+ export async function loginWithToken(token) {
39
+ const payload = verifyAndDecodeLicense(token);
40
+ await saveLicenseCache(token, payload);
41
+ return payload;
42
+ }
@@ -0,0 +1,3 @@
1
+ export declare function enforceBeforeSecretAccess(projectPath?: string): Promise<void>;
2
+ export declare function enforceBeforeVaultWrite(): Promise<void>;
3
+ export declare function enforceOnLogin(): Promise<void>;
@@ -0,0 +1,20 @@
1
+ import { getEffectiveAccess, requireHardenedApproval } from "./access.js";
2
+ import { countVaults, registerProject, registerThisDevice, } from "./registry.js";
3
+ export async function enforceBeforeSecretAccess(projectPath) {
4
+ const { payload } = await getEffectiveAccess();
5
+ requireHardenedApproval(payload);
6
+ if (projectPath) {
7
+ await registerProject(projectPath, payload.projects);
8
+ }
9
+ }
10
+ export async function enforceBeforeVaultWrite() {
11
+ const { payload } = await getEffectiveAccess();
12
+ const vaults = countVaults();
13
+ if (vaults >= payload.vaults) {
14
+ throw new Error(`Vault limit reached (${payload.vaults}). Upgrade at https://modexventures.com/latchkey/pricing`);
15
+ }
16
+ }
17
+ export async function enforceOnLogin() {
18
+ const { payload } = await getEffectiveAccess();
19
+ await registerThisDevice(payload.devices);
20
+ }