@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.
- package/README.md +48 -2
- package/dist/audit.d.ts +11 -0
- package/dist/audit.js +7 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +239 -0
- package/dist/crypto.d.ts +18 -0
- package/dist/crypto.js +43 -0
- package/dist/crypto.test.d.ts +1 -0
- package/dist/crypto.test.js +21 -0
- package/dist/daemon.d.ts +2 -0
- package/dist/daemon.js +45 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/keychain.d.ts +8 -0
- package/dist/keychain.js +73 -0
- package/dist/licensing/access.d.ts +8 -0
- package/dist/licensing/access.js +35 -0
- package/dist/licensing/cache.d.ts +11 -0
- package/dist/licensing/cache.js +42 -0
- package/dist/licensing/enforce.d.ts +3 -0
- package/dist/licensing/enforce.js +20 -0
- package/dist/licensing/enforce.test.d.ts +1 -0
- package/dist/licensing/enforce.test.js +57 -0
- package/dist/licensing/registry.d.ts +5 -0
- package/dist/licensing/registry.js +59 -0
- package/dist/licensing/trial.d.ts +6 -0
- package/dist/licensing/trial.js +23 -0
- package/dist/lock.d.ts +6 -0
- package/dist/lock.js +55 -0
- package/dist/mcp-server.d.ts +2 -0
- package/dist/mcp-server.js +82 -0
- package/dist/paths.d.ts +13 -0
- package/dist/paths.js +15 -0
- package/dist/redact.d.ts +5 -0
- package/dist/redact.js +32 -0
- package/dist/redact.test.d.ts +1 -0
- package/dist/redact.test.js +12 -0
- package/dist/sandbox.d.ts +4 -0
- package/dist/sandbox.js +10 -0
- package/dist/scan.d.ts +6 -0
- package/dist/scan.js +50 -0
- package/dist/scope.d.ts +9 -0
- package/dist/scope.js +36 -0
- package/dist/scope.test.d.ts +1 -0
- package/dist/scope.test.js +15 -0
- package/dist/vault.d.ts +35 -0
- package/dist/vault.js +171 -0
- package/dist/vault.test.d.ts +1 -0
- package/dist/vault.test.js +32 -0
- package/package.json +38 -4
package/README.md
CHANGED
|
@@ -1,3 +1,49 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Latchkey CLI
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
package/dist/audit.d.ts
ADDED
|
@@ -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
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();
|
package/dist/crypto.d.ts
ADDED
|
@@ -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
|
+
});
|
package/dist/daemon.d.ts
ADDED
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();
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -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;
|
package/dist/keychain.js
ADDED
|
@@ -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,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
|
+
}
|