@centient/secrets 0.5.0 → 0.7.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 +39 -1
- package/dist/cli/hidden-input.d.ts +52 -0
- package/dist/cli/hidden-input.d.ts.map +1 -0
- package/dist/cli/hidden-input.js +109 -0
- package/dist/cli/hidden-input.js.map +1 -0
- package/dist/cli/hidden-prompt.d.ts +48 -0
- package/dist/cli/hidden-prompt.d.ts.map +1 -0
- package/dist/cli/hidden-prompt.js +127 -0
- package/dist/cli/hidden-prompt.js.map +1 -0
- package/dist/cli/secrets-cli.d.ts +9 -1
- package/dist/cli/secrets-cli.d.ts.map +1 -1
- package/dist/cli/secrets-cli.js +272 -175
- package/dist/cli/secrets-cli.js.map +1 -1
- package/dist/crypto/vault-common.d.ts +17 -4
- package/dist/crypto/vault-common.d.ts.map +1 -1
- package/dist/crypto/vault-common.js +23 -6
- package/dist/crypto/vault-common.js.map +1 -1
- package/dist/index.d.ts +7 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/dist/key-providers/index.d.ts +4 -1
- package/dist/key-providers/index.d.ts.map +1 -1
- package/dist/key-providers/index.js +1 -0
- package/dist/key-providers/index.js.map +1 -1
- package/dist/key-providers/passphrase-provider.d.ts +79 -0
- package/dist/key-providers/passphrase-provider.d.ts.map +1 -0
- package/dist/key-providers/passphrase-provider.js +298 -0
- package/dist/key-providers/passphrase-provider.js.map +1 -0
- package/dist/key-providers/resolve.d.ts +8 -3
- package/dist/key-providers/resolve.d.ts.map +1 -1
- package/dist/key-providers/resolve.js +34 -10
- package/dist/key-providers/resolve.js.map +1 -1
- package/dist/key-providers/types.d.ts +20 -2
- package/dist/key-providers/types.d.ts.map +1 -1
- package/dist/key-providers/types.js +1 -1
- package/dist/vault/file-lock.d.ts +33 -0
- package/dist/vault/file-lock.d.ts.map +1 -0
- package/dist/vault/file-lock.js +143 -0
- package/dist/vault/file-lock.js.map +1 -0
- package/dist/vault/session-vault-errors.d.ts +38 -0
- package/dist/vault/session-vault-errors.d.ts.map +1 -0
- package/dist/vault/session-vault-errors.js +67 -0
- package/dist/vault/session-vault-errors.js.map +1 -0
- package/dist/vault/session-vault.d.ts +147 -0
- package/dist/vault/session-vault.d.ts.map +1 -0
- package/dist/vault/session-vault.js +669 -0
- package/dist/vault/session-vault.js.map +1 -0
- package/dist/vault/sidecar.d.ts +37 -0
- package/dist/vault/sidecar.d.ts.map +1 -0
- package/dist/vault/sidecar.js +84 -0
- package/dist/vault/sidecar.js.map +1 -0
- package/dist/vault/types.d.ts +1 -1
- package/dist/vault/types.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/cli/secrets-cli.js
CHANGED
|
@@ -25,13 +25,23 @@
|
|
|
25
25
|
* Security:
|
|
26
26
|
* - All commands check for AI agent environment and refuse to run
|
|
27
27
|
* - Vault is encrypted with AES-256-GCM
|
|
28
|
-
* - Key stored via pluggable provider (macOS Keychain
|
|
28
|
+
* - Key stored or derived via pluggable provider (macOS Keychain, 1Password,
|
|
29
|
+
* or passphrase)
|
|
29
30
|
* - 4-hour session timeout
|
|
31
|
+
*
|
|
32
|
+
* Internals:
|
|
33
|
+
* Vault mutations flow through `openVault` (session-vault.ts) so the CLI
|
|
34
|
+
* and the library-facing API share one on-disk format (AAD-bound, schema 1).
|
|
35
|
+
* Pre-`openVault` CLI-written vaults (AAD-less, legacy flat format) are
|
|
36
|
+
* handled transparently by session-vault's legacy-read path and upgraded to
|
|
37
|
+
* schema 1 on the next successful write.
|
|
30
38
|
*/
|
|
31
39
|
import { createInterface } from "readline";
|
|
32
|
-
import { join } from "path";
|
|
33
|
-
import { homedir } from "os";
|
|
34
|
-
import { existsSync } from "fs";
|
|
40
|
+
import { dirname, join } from "node:path";
|
|
41
|
+
import { homedir } from "node:os";
|
|
42
|
+
import { existsSync, mkdirSync, realpathSync, writeFileSync, } from "node:fs";
|
|
43
|
+
import { basename } from "node:path";
|
|
44
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
35
45
|
// =============================================================================
|
|
36
46
|
// Agent Detection (Local Implementation)
|
|
37
47
|
// =============================================================================
|
|
@@ -46,48 +56,31 @@ function isAgentEnvironment() {
|
|
|
46
56
|
process.env.CLAUDE_CODE_ENTRY_POINT);
|
|
47
57
|
}
|
|
48
58
|
// =============================================================================
|
|
49
|
-
//
|
|
59
|
+
// Session vault integration
|
|
50
60
|
// =============================================================================
|
|
51
|
-
import {
|
|
52
|
-
import { randomBytes } from "crypto";
|
|
53
|
-
import { encryptObject, decryptObject, } from "../crypto/vault-common.js";
|
|
61
|
+
import { encryptObject } from "../crypto/vault-common.js";
|
|
54
62
|
import { resolveKeyProvider, getProviderByType, loadConfig, saveSecretsConfig, } from "../key-providers/index.js";
|
|
55
63
|
import { listCredentials, getActiveVaultType } from "../vault/vault.js";
|
|
64
|
+
import { openVault, VAULT_SCHEMA_VERSION, VAULT_AAD_PREFIX, DEFAULT_SIDECAR_PATH, } from "../vault/session-vault.js";
|
|
56
65
|
const VAULT_PATH = join(homedir(), ".centient", "secrets", "vault.enc");
|
|
57
66
|
const KEY_LENGTH = 32;
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
67
|
+
/** Preserve CLI's historical 4-hour auto-lock semantics. */
|
|
68
|
+
const SESSION_TTL_MS = 4 * 60 * 60 * 1000;
|
|
69
|
+
/**
|
|
70
|
+
* Single process-wide vault handle. Non-null => "session is unlocked".
|
|
71
|
+
* Replaces the previous `sessionKey` + `sessionUnlockedAt` pair. Auto-close
|
|
72
|
+
* on TTL is delegated to `openVault({ ttlMs })`, which clears this handle
|
|
73
|
+
* from inside itself — we observe the closed state via `isSessionValid`.
|
|
74
|
+
*/
|
|
75
|
+
let vault = null;
|
|
62
76
|
function isSessionValid() {
|
|
63
|
-
|
|
64
|
-
return false;
|
|
65
|
-
return Date.now() - sessionUnlockedAt < SESSION_TTL;
|
|
66
|
-
}
|
|
67
|
-
function encrypt(data, key) {
|
|
68
|
-
const result = encryptObject(data, key);
|
|
69
|
-
if (!result)
|
|
70
|
-
throw new Error("Encryption failed");
|
|
71
|
-
return result;
|
|
72
|
-
}
|
|
73
|
-
function decrypt(data, key) {
|
|
74
|
-
const parsed = decryptObject(data, key);
|
|
75
|
-
if (!parsed)
|
|
76
|
-
return null;
|
|
77
|
-
// Validate all values are strings
|
|
78
|
-
const result = {};
|
|
79
|
-
for (const [k, v] of Object.entries(parsed)) {
|
|
80
|
-
if (typeof v !== "string")
|
|
81
|
-
return null;
|
|
82
|
-
result[k] = v;
|
|
83
|
-
}
|
|
84
|
-
return result;
|
|
77
|
+
return vault !== null;
|
|
85
78
|
}
|
|
86
79
|
/**
|
|
87
80
|
* Resolve the active key provider, printing an error if unavailable.
|
|
88
81
|
*/
|
|
89
82
|
function getProvider() {
|
|
90
|
-
const result = resolveKeyProvider();
|
|
83
|
+
const result = resolveKeyProvider({ vaultPath: VAULT_PATH });
|
|
91
84
|
if (!result.ok) {
|
|
92
85
|
console.error(`❌ ${result.error.message}`);
|
|
93
86
|
return null;
|
|
@@ -97,46 +90,73 @@ function getProvider() {
|
|
|
97
90
|
// =============================================================================
|
|
98
91
|
// CLI Handlers
|
|
99
92
|
// =============================================================================
|
|
93
|
+
import { advanceHiddenInput, createHiddenInputState, ENABLE_BRACKETED_PASTE, DISABLE_BRACKETED_PASTE, } from "./hidden-input.js";
|
|
100
94
|
/**
|
|
101
|
-
* Prompt for input (with optional hidden mode for passwords)
|
|
95
|
+
* Prompt for input (with optional hidden mode for passwords/secrets).
|
|
96
|
+
*
|
|
97
|
+
* Handles three input shapes correctly:
|
|
98
|
+
* 1. **Piped stdin** (`echo "value" | centient secrets set ...`): reads the
|
|
99
|
+
* full stream to EOF, trims a single trailing newline (pipe artifact).
|
|
100
|
+
* Multi-line values pass through unchanged.
|
|
101
|
+
* 2. **Interactive TTY with bracketed paste**: content wrapped in
|
|
102
|
+
* `\x1b[200~ ... \x1b[201~` is treated atomically; newlines inside a
|
|
103
|
+
* paste are literal content, not submit signals.
|
|
104
|
+
* 3. **Interactive TTY without bracketed paste**: a single newline still
|
|
105
|
+
* submits (preserves single-line UX), and Ctrl-D is an escape hatch for
|
|
106
|
+
* submitting multi-line content on terminals that don't emit paste
|
|
107
|
+
* brackets.
|
|
108
|
+
*
|
|
109
|
+
* Regression hook for issue #37: the previous implementation resolved on the
|
|
110
|
+
* first `\n` from a terminal paste and silently truncated PEM keys / other
|
|
111
|
+
* multi-line secrets to their first line. The parsing state machine lives in
|
|
112
|
+
* `./hidden-input.ts` so it can be unit-tested without stubbing process.stdin.
|
|
102
113
|
*/
|
|
103
114
|
async function prompt(message, hidden = false) {
|
|
115
|
+
// Non-TTY stdin: don't touch raw mode, don't use readline. Read the whole
|
|
116
|
+
// stream to EOF. This is the path for `cat key.pem | centient secrets set`
|
|
117
|
+
// and the pattern most CLIs use for piped-value workflows.
|
|
118
|
+
if (!process.stdin.isTTY) {
|
|
119
|
+
process.stdout.write(message);
|
|
120
|
+
const chunks = [];
|
|
121
|
+
for await (const chunk of process.stdin) {
|
|
122
|
+
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
123
|
+
}
|
|
124
|
+
// Strip a single trailing newline — pipes and `<<<` heredocs typically
|
|
125
|
+
// append one. Preserve any other trailing whitespace (matters for PEM).
|
|
126
|
+
return Buffer.concat(chunks).toString("utf8").replace(/\r?\n$/, "");
|
|
127
|
+
}
|
|
104
128
|
const rl = createInterface({
|
|
105
129
|
input: process.stdin,
|
|
106
130
|
output: process.stdout,
|
|
107
131
|
});
|
|
108
132
|
return new Promise((resolve) => {
|
|
109
133
|
if (hidden) {
|
|
110
|
-
// For hidden input, we need to handle it differently
|
|
111
134
|
process.stdout.write(message);
|
|
112
|
-
|
|
135
|
+
const state = createHiddenInputState();
|
|
113
136
|
const stdin = process.stdin;
|
|
114
137
|
const wasRaw = stdin.isRaw;
|
|
115
138
|
stdin.setRawMode?.(true);
|
|
139
|
+
process.stdout.write(ENABLE_BRACKETED_PASTE);
|
|
116
140
|
stdin.resume();
|
|
117
141
|
stdin.setEncoding("utf8");
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
142
|
+
const finish = () => {
|
|
143
|
+
process.stdout.write(DISABLE_BRACKETED_PASTE);
|
|
144
|
+
stdin.setRawMode?.(wasRaw || false);
|
|
145
|
+
stdin.pause();
|
|
146
|
+
stdin.removeListener("data", onData);
|
|
147
|
+
process.stdout.write("\n");
|
|
148
|
+
rl.close();
|
|
149
|
+
resolve(state.input);
|
|
150
|
+
};
|
|
151
|
+
// Terminals can deliver a paste as one large chunk OR as many small
|
|
152
|
+
// chunks; the state machine processes character-by-character so
|
|
153
|
+
// escape-sequence state survives across chunk boundaries.
|
|
154
|
+
const onData = (chunk) => {
|
|
155
|
+
const signal = advanceHiddenInput(state, chunk);
|
|
156
|
+
if (signal === "ctrl-c")
|
|
129
157
|
process.exit(0);
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
// Backspace
|
|
133
|
-
if (input.length > 0) {
|
|
134
|
-
input = input.slice(0, -1);
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
else {
|
|
138
|
-
input += char;
|
|
139
|
-
}
|
|
158
|
+
if (signal === "submit")
|
|
159
|
+
finish();
|
|
140
160
|
};
|
|
141
161
|
stdin.on("data", onData);
|
|
142
162
|
}
|
|
@@ -148,6 +168,31 @@ async function prompt(message, hidden = false) {
|
|
|
148
168
|
}
|
|
149
169
|
});
|
|
150
170
|
}
|
|
171
|
+
/**
|
|
172
|
+
* Derive the AAD for a freshly-initialised vault so the bootstrap blob is
|
|
173
|
+
* openable by `openVault()` on the very next invocation. Mirrors the
|
|
174
|
+
* derivation in session-vault.ts (same path-resolution + hash construction)
|
|
175
|
+
* because we can't call the private helper from here.
|
|
176
|
+
*
|
|
177
|
+
* Note on symlink handling: session-vault's `resolveVaultPath` realpaths the
|
|
178
|
+
* vault file. Because the vault file doesn't exist yet at bootstrap time, we
|
|
179
|
+
* realpath the PARENT directory (already created) and append the basename —
|
|
180
|
+
* which reproduces the same resolved-path bytes that realpath would produce
|
|
181
|
+
* on the file itself after the first openVault call. Absent this, a setup
|
|
182
|
+
* where any parent component is a symlink (e.g. `~/.centient` pointing into
|
|
183
|
+
* iCloud Drive on macOS) would produce a different AAD on init vs on unlock,
|
|
184
|
+
* and the first unlock-after-init would fail with VaultDecryptError.
|
|
185
|
+
*
|
|
186
|
+
* @param vaultPathAbs - Absolute vault file path whose PARENT directory must
|
|
187
|
+
* already exist on disk so `realpathSync` can resolve it.
|
|
188
|
+
*/
|
|
189
|
+
function deriveBootstrapAad(vaultPathAbs) {
|
|
190
|
+
const parentReal = realpathSync(dirname(vaultPathAbs));
|
|
191
|
+
const resolved = join(parentReal, basename(vaultPathAbs));
|
|
192
|
+
return createHash("sha256")
|
|
193
|
+
.update(`${VAULT_AAD_PREFIX}:v${VAULT_SCHEMA_VERSION}:${resolved}`)
|
|
194
|
+
.digest();
|
|
195
|
+
}
|
|
151
196
|
/**
|
|
152
197
|
* Initialize a new vault
|
|
153
198
|
*/
|
|
@@ -160,28 +205,78 @@ async function initVault() {
|
|
|
160
205
|
return;
|
|
161
206
|
}
|
|
162
207
|
}
|
|
163
|
-
// Generate key
|
|
164
|
-
const key = randomBytes(KEY_LENGTH);
|
|
165
208
|
// Store via key provider
|
|
166
209
|
const provider = getProvider();
|
|
167
210
|
if (!provider)
|
|
168
211
|
return;
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
212
|
+
// Ensure directory exists BEFORE computing AAD so realpath inside
|
|
213
|
+
// openVault can resolve parent components cleanly on the next open.
|
|
214
|
+
const dir = dirname(VAULT_PATH);
|
|
215
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
216
|
+
let key;
|
|
217
|
+
if (provider.setupKey) {
|
|
218
|
+
process.stdout.write(`Configuring vault key via ${provider.name}...\n`);
|
|
219
|
+
const setupKey = provider.setupKey();
|
|
220
|
+
if (!setupKey) {
|
|
221
|
+
const providerError = provider.getLastError?.();
|
|
222
|
+
console.error(`❌ Failed to configure key via ${provider.name}`);
|
|
223
|
+
if (providerError)
|
|
224
|
+
console.error(` ${providerError.message}`);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
key = setupKey;
|
|
228
|
+
}
|
|
229
|
+
else {
|
|
230
|
+
key = randomBytes(KEY_LENGTH);
|
|
231
|
+
process.stdout.write(`Storing encryption key via ${provider.name}...\n`);
|
|
232
|
+
if (!provider.storeKey(key)) {
|
|
233
|
+
const providerError = provider.getLastError?.();
|
|
234
|
+
console.error(`❌ Failed to store key via ${provider.name}`);
|
|
235
|
+
if (providerError)
|
|
236
|
+
console.error(` ${providerError.message}`);
|
|
237
|
+
key.fill(0);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
// Bootstrap the vault file in the session-vault v1 format (AAD-bound,
|
|
242
|
+
// `{schema, vaultVersion, secrets}` payload) so the next `openVault()` call
|
|
243
|
+
// succeeds without hitting the legacy-upgrade path. The alternative —
|
|
244
|
+
// writing an AAD-less blob — would force a write on first unlock just to
|
|
245
|
+
// upgrade schemas, which is both weirder and slower.
|
|
246
|
+
try {
|
|
247
|
+
const aad = deriveBootstrapAad(VAULT_PATH);
|
|
248
|
+
const bootstrapPayload = {
|
|
249
|
+
schema: VAULT_SCHEMA_VERSION,
|
|
250
|
+
vaultVersion: 1,
|
|
251
|
+
secrets: {},
|
|
252
|
+
};
|
|
253
|
+
const encrypted = encryptObject(bootstrapPayload, key, aad);
|
|
254
|
+
if (!encrypted) {
|
|
255
|
+
console.error("❌ Failed to encrypt empty vault");
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
writeFileSync(VAULT_PATH, encrypted, { mode: 0o600 });
|
|
259
|
+
// Write the sidecar at the default location so openVault doesn't warn on
|
|
260
|
+
// missing-sidecar the first time we open. Matches `DEFAULT_SIDECAR_PATH`.
|
|
261
|
+
writeFileSync(DEFAULT_SIDECAR_PATH, JSON.stringify({ highestSeenVersion: 1 }), { mode: 0o600 });
|
|
262
|
+
// Open the vault immediately so the CLI session is already unlocked —
|
|
263
|
+
// same UX as before, but via the shared code path.
|
|
264
|
+
vault = await openVault({
|
|
265
|
+
path: VAULT_PATH,
|
|
266
|
+
ttlMs: SESSION_TTL_MS,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
catch (err) {
|
|
270
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
271
|
+
console.error(`⚠️ Vault written but failed to auto-unlock: ${message}`);
|
|
272
|
+
console.error(" Run 'centient secrets unlock' to unlock manually.");
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
finally {
|
|
276
|
+
// Zero the local key copy — `openVault` fetched its own copy from the
|
|
277
|
+
// provider; we never need the original again.
|
|
278
|
+
key.fill(0);
|
|
279
|
+
}
|
|
185
280
|
process.stdout.write("\n✅ Vault initialized successfully!\n");
|
|
186
281
|
process.stdout.write(` Location: ${VAULT_PATH}\n`);
|
|
187
282
|
process.stdout.write(" The vault is now unlocked. Use 'centient secrets set' to add secrets.\n\n");
|
|
@@ -195,64 +290,66 @@ async function unlockVault() {
|
|
|
195
290
|
console.error("❌ Vault not found. Run 'centient secrets init' first.");
|
|
196
291
|
return false;
|
|
197
292
|
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
293
|
+
try {
|
|
294
|
+
vault = await openVault({
|
|
295
|
+
path: VAULT_PATH,
|
|
296
|
+
// Existing CLI-written vaults predate the sidecar; session-vault's
|
|
297
|
+
// legacy-upgrade path treats them as a first-use context, but explicit
|
|
298
|
+
// opt-in is required for non-legacy fresh installs. We set this to
|
|
299
|
+
// `true` to keep the first-unlock-after-init path from failing on
|
|
300
|
+
// sidecar-missing (init writes the sidecar, but a manual
|
|
301
|
+
// backup-without-sidecar restore shouldn't brick the CLI either).
|
|
302
|
+
acceptMissingSidecar: true,
|
|
303
|
+
// Preserve the CLI's 4-hour auto-lock behaviour.
|
|
304
|
+
ttlMs: SESSION_TTL_MS,
|
|
305
|
+
});
|
|
207
306
|
}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
if (!secrets) {
|
|
212
|
-
console.error("❌ Failed to decrypt vault - key may be incorrect");
|
|
307
|
+
catch (err) {
|
|
308
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
309
|
+
console.error(`❌ Failed to unlock vault: ${message}`);
|
|
213
310
|
return false;
|
|
214
311
|
}
|
|
215
|
-
// Set session
|
|
216
|
-
sessionKey = key;
|
|
217
|
-
sessionUnlockedAt = Date.now();
|
|
218
312
|
process.stdout.write("✅ Vault unlocked successfully!\n");
|
|
219
|
-
process.stdout.write(` Session valid for 4 hours.\n\n`);
|
|
313
|
+
process.stdout.write(` Session valid for 4 hours (provider: ${vault.provider}).\n\n`);
|
|
220
314
|
return true;
|
|
221
315
|
}
|
|
222
316
|
/**
|
|
223
317
|
* Lock the vault
|
|
224
318
|
*/
|
|
225
319
|
function lockVault() {
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
}
|
|
229
|
-
sessionKey = null;
|
|
230
|
-
sessionUnlockedAt = null;
|
|
320
|
+
vault?.close();
|
|
321
|
+
vault = null;
|
|
231
322
|
process.stdout.write("\n🔒 Vault locked.\n\n");
|
|
232
323
|
}
|
|
324
|
+
/**
|
|
325
|
+
* Ensure the session is unlocked; unlock on demand if not. Returns false when
|
|
326
|
+
* unlock fails so callers can abort cleanly. Factored out of every operation
|
|
327
|
+
* (list / set / get / delete / status) to eliminate boilerplate duplication.
|
|
328
|
+
*/
|
|
329
|
+
async function ensureUnlocked() {
|
|
330
|
+
if (isSessionValid())
|
|
331
|
+
return true;
|
|
332
|
+
process.stdout.write("\n🔒 Vault is locked. Unlocking...\n");
|
|
333
|
+
return unlockVault();
|
|
334
|
+
}
|
|
233
335
|
/**
|
|
234
336
|
* List secrets
|
|
235
337
|
*/
|
|
236
338
|
async function listSecrets() {
|
|
237
|
-
if (!
|
|
238
|
-
process.stdout.write("\n🔒 Vault is locked. Unlocking...\n");
|
|
239
|
-
if (!(await unlockVault()))
|
|
240
|
-
return;
|
|
241
|
-
}
|
|
242
|
-
const data = readFileSync(VAULT_PATH);
|
|
243
|
-
const secrets = decrypt(data, sessionKey);
|
|
244
|
-
if (!secrets) {
|
|
245
|
-
console.error("❌ Failed to decrypt vault");
|
|
339
|
+
if (!(await ensureUnlocked()))
|
|
246
340
|
return;
|
|
247
|
-
|
|
248
|
-
const names = Object.keys(secrets).sort();
|
|
341
|
+
const names = await vault.list();
|
|
249
342
|
process.stdout.write(`\n📋 Secrets in vault (${names.length}):\n\n`);
|
|
250
343
|
if (names.length === 0) {
|
|
251
344
|
process.stdout.write(" (empty - use 'centient secrets set <name>' to add secrets)\n");
|
|
252
345
|
}
|
|
253
346
|
else {
|
|
254
347
|
for (const name of names) {
|
|
255
|
-
|
|
348
|
+
// Per-name `get` is the only way to obtain values via the session-vault
|
|
349
|
+
// API — there's no `getAll`. For a vault with hundreds of entries this
|
|
350
|
+
// is O(n) syscall-free reads (RAM hit), so we eat the minor overhead in
|
|
351
|
+
// exchange for keeping session-vault's surface small.
|
|
352
|
+
const value = (await vault.get(name)) ?? "";
|
|
256
353
|
const preview = value.length > 0 ? "•".repeat(Math.min(value.length, 20)) : "(empty)";
|
|
257
354
|
process.stdout.write(` ${name.padEnd(30)} ${preview}\n`);
|
|
258
355
|
}
|
|
@@ -309,18 +406,13 @@ async function setSecret(name) {
|
|
|
309
406
|
console.error("❌ Secret name required. Usage: centient secrets set <name>");
|
|
310
407
|
return;
|
|
311
408
|
}
|
|
312
|
-
if (!
|
|
313
|
-
process.stdout.write("\n🔒 Vault is locked. Unlocking...\n");
|
|
314
|
-
if (!(await unlockVault()))
|
|
315
|
-
return;
|
|
316
|
-
}
|
|
317
|
-
const data = readFileSync(VAULT_PATH);
|
|
318
|
-
const secrets = decrypt(data, sessionKey);
|
|
319
|
-
if (!secrets) {
|
|
320
|
-
console.error("❌ Failed to decrypt vault");
|
|
409
|
+
if (!(await ensureUnlocked()))
|
|
321
410
|
return;
|
|
322
|
-
|
|
323
|
-
|
|
411
|
+
// Cheap pre-check so we can show "updating" vs "adding" in the prompt.
|
|
412
|
+
// The write path below is authoritative; races with an external writer
|
|
413
|
+
// don't matter for this cosmetic distinction.
|
|
414
|
+
const existing = await vault.get(name);
|
|
415
|
+
const exists = existing !== null;
|
|
324
416
|
const action = exists ? "update" : "add";
|
|
325
417
|
process.stdout.write(`\n${exists ? "✏️ Updating" : "➕ Adding"} secret: ${name}\n\n`);
|
|
326
418
|
const value = await prompt(`Enter value for ${name}: `, true);
|
|
@@ -328,10 +420,14 @@ async function setSecret(name) {
|
|
|
328
420
|
process.stdout.write("Aborted - empty value.\n");
|
|
329
421
|
return;
|
|
330
422
|
}
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
423
|
+
try {
|
|
424
|
+
await vault.set(name, value);
|
|
425
|
+
}
|
|
426
|
+
catch (err) {
|
|
427
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
428
|
+
console.error(`❌ Failed to save secret: ${message}`);
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
335
431
|
process.stdout.write(`\n✅ Secret '${name}' ${action}d successfully!\n\n`);
|
|
336
432
|
}
|
|
337
433
|
/**
|
|
@@ -342,26 +438,16 @@ async function getSecret(name) {
|
|
|
342
438
|
console.error("❌ Secret name required. Usage: centient secrets get <name>");
|
|
343
439
|
return;
|
|
344
440
|
}
|
|
345
|
-
if (!
|
|
346
|
-
process.stdout.write("\n🔒 Vault is locked. Unlocking...\n");
|
|
347
|
-
if (!(await unlockVault()))
|
|
348
|
-
return;
|
|
349
|
-
}
|
|
350
|
-
const data = readFileSync(VAULT_PATH);
|
|
351
|
-
const secrets = decrypt(data, sessionKey);
|
|
352
|
-
if (!secrets) {
|
|
353
|
-
console.error("❌ Failed to decrypt vault");
|
|
441
|
+
if (!(await ensureUnlocked()))
|
|
354
442
|
return;
|
|
355
|
-
|
|
356
|
-
if (
|
|
443
|
+
const value = await vault.get(name);
|
|
444
|
+
if (value === null) {
|
|
357
445
|
console.error(`❌ Secret '${name}' not found`);
|
|
358
446
|
return;
|
|
359
447
|
}
|
|
360
|
-
// Print without newline for piping
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
process.stdout.write(secretValue);
|
|
364
|
-
}
|
|
448
|
+
// Print without newline for piping, then add trailing newline for TTY
|
|
449
|
+
// readability — matches the previous behaviour exactly.
|
|
450
|
+
process.stdout.write(value);
|
|
365
451
|
process.stdout.write("\n");
|
|
366
452
|
}
|
|
367
453
|
/**
|
|
@@ -372,18 +458,14 @@ async function deleteSecret(name) {
|
|
|
372
458
|
console.error("❌ Secret name required. Usage: centient secrets delete <name>");
|
|
373
459
|
return;
|
|
374
460
|
}
|
|
375
|
-
if (!
|
|
376
|
-
process.stdout.write("\n🔒 Vault is locked. Unlocking...\n");
|
|
377
|
-
if (!(await unlockVault()))
|
|
378
|
-
return;
|
|
379
|
-
}
|
|
380
|
-
const data = readFileSync(VAULT_PATH);
|
|
381
|
-
const secrets = decrypt(data, sessionKey);
|
|
382
|
-
if (!secrets) {
|
|
383
|
-
console.error("❌ Failed to decrypt vault");
|
|
461
|
+
if (!(await ensureUnlocked()))
|
|
384
462
|
return;
|
|
385
|
-
|
|
386
|
-
|
|
463
|
+
// Pre-check so we can distinguish "not found" from "declined to confirm" in
|
|
464
|
+
// the error output. The subsequent `vault.delete()` is authoritative — a
|
|
465
|
+
// concurrent external delete between the get and the delete just means the
|
|
466
|
+
// final delete reports `false`, which we treat the same as not-found.
|
|
467
|
+
const current = await vault.get(name);
|
|
468
|
+
if (current === null) {
|
|
387
469
|
console.error(`❌ Secret '${name}' not found`);
|
|
388
470
|
return;
|
|
389
471
|
}
|
|
@@ -392,10 +474,19 @@ async function deleteSecret(name) {
|
|
|
392
474
|
process.stdout.write("Aborted.\n");
|
|
393
475
|
return;
|
|
394
476
|
}
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
477
|
+
try {
|
|
478
|
+
const existed = await vault.delete(name);
|
|
479
|
+
if (!existed) {
|
|
480
|
+
// Lost a race with a concurrent deleter — report the end state.
|
|
481
|
+
console.error(`❌ Secret '${name}' not found`);
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
catch (err) {
|
|
486
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
487
|
+
console.error(`❌ Failed to delete secret: ${message}`);
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
399
490
|
process.stdout.write(`\n✅ Secret '${name}' deleted.\n\n`);
|
|
400
491
|
}
|
|
401
492
|
/**
|
|
@@ -508,14 +599,8 @@ async function showStatus() {
|
|
|
508
599
|
// Check session
|
|
509
600
|
const sessionValid = isSessionValid();
|
|
510
601
|
process.stdout.write(`Session: ${sessionValid ? "🔓 unlocked" : "🔒 locked"}\n`);
|
|
511
|
-
if (sessionValid && sessionUnlockedAt) {
|
|
512
|
-
const remaining = SESSION_TTL - (Date.now() - sessionUnlockedAt);
|
|
513
|
-
const hours = Math.floor(remaining / (60 * 60 * 1000));
|
|
514
|
-
const minutes = Math.floor((remaining % (60 * 60 * 1000)) / (60 * 1000));
|
|
515
|
-
process.stdout.write(`Expires in: ${hours}h ${minutes}m\n`);
|
|
516
|
-
}
|
|
517
602
|
// Check key provider
|
|
518
|
-
const providerResult = resolveKeyProvider();
|
|
603
|
+
const providerResult = resolveKeyProvider({ vaultPath: VAULT_PATH });
|
|
519
604
|
if (providerResult.ok) {
|
|
520
605
|
const hasKey = providerResult.provider.getKey() !== null;
|
|
521
606
|
process.stdout.write(`Key provider: ${providerResult.provider.name} (${providerResult.method})\n`);
|
|
@@ -524,12 +609,19 @@ async function showStatus() {
|
|
|
524
609
|
else {
|
|
525
610
|
process.stdout.write(`Key provider: ❌ unavailable\n`);
|
|
526
611
|
}
|
|
527
|
-
//
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
612
|
+
// If unlocked, surface the session-vault diagnostic fields (provider that
|
|
613
|
+
// unlocked, resolved vault path, in-memory version) + secret count.
|
|
614
|
+
if (sessionValid && vault) {
|
|
615
|
+
process.stdout.write(`Open via: ${vault.provider}\n`);
|
|
616
|
+
process.stdout.write(`Resolved path: ${vault.path}\n`);
|
|
617
|
+
process.stdout.write(`Vault version: ${vault.vaultVersion}\n`);
|
|
618
|
+
try {
|
|
619
|
+
const names = await vault.list();
|
|
620
|
+
process.stdout.write(`Secrets count: ${names.length}\n`);
|
|
621
|
+
}
|
|
622
|
+
catch (err) {
|
|
623
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
624
|
+
process.stdout.write(`Secrets count: (unavailable: ${message})\n`);
|
|
533
625
|
}
|
|
534
626
|
}
|
|
535
627
|
process.stdout.write("\u2500".repeat(40) + "\n");
|
|
@@ -545,17 +637,22 @@ async function showStatus() {
|
|
|
545
637
|
async function migrateProvider(targetType) {
|
|
546
638
|
if (!targetType) {
|
|
547
639
|
console.error("❌ Target provider required. Usage: centient secrets migrate <provider>");
|
|
548
|
-
console.error(" Supported providers: keychain, 1password");
|
|
640
|
+
console.error(" Supported providers: keychain, 1password, passphrase");
|
|
549
641
|
return;
|
|
550
642
|
}
|
|
551
|
-
const validTypes = ["keychain", "1password"];
|
|
643
|
+
const validTypes = ["keychain", "1password", "passphrase"];
|
|
552
644
|
if (!validTypes.includes(targetType)) {
|
|
553
645
|
console.error(`❌ Unknown provider "${targetType}". Supported: ${validTypes.join(", ")}`);
|
|
554
646
|
return;
|
|
555
647
|
}
|
|
556
648
|
const target = targetType;
|
|
649
|
+
if (target === "passphrase") {
|
|
650
|
+
console.error("❌ Migrating to passphrase is not supported by this provider-only step.");
|
|
651
|
+
console.error(" It requires re-encrypting the vault under a derived key and will be handled in a follow-up.");
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
557
654
|
// Resolve current provider
|
|
558
|
-
const currentResult = resolveKeyProvider();
|
|
655
|
+
const currentResult = resolveKeyProvider({ vaultPath: VAULT_PATH });
|
|
559
656
|
if (!currentResult.ok) {
|
|
560
657
|
console.error(`❌ ${currentResult.error.message}`);
|
|
561
658
|
return;
|
|
@@ -575,7 +672,7 @@ async function migrateProvider(targetType) {
|
|
|
575
672
|
}
|
|
576
673
|
// Create target provider
|
|
577
674
|
const config = loadConfig();
|
|
578
|
-
const targetProvider = getProviderByType(target, config.secrets);
|
|
675
|
+
const targetProvider = getProviderByType(target, config.secrets, { vaultPath: VAULT_PATH });
|
|
579
676
|
if (!targetProvider) {
|
|
580
677
|
console.error(`❌ Provider "${target}" is not available on this system.`);
|
|
581
678
|
if (target === "1password") {
|