@indigoai-us/hq-cli 5.107.0 → 5.107.1
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/CHANGELOG.md +2 -0
- package/dist/commands/secrets.d.ts +1 -0
- package/dist/commands/secrets.js +28 -2
- package/dist/run/hq-plugin.js +1 -1
- package/dist/utils/secrets-cache.d.ts +8 -2
- package/dist/utils/secrets-cache.js +150 -34
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
package/dist/commands/secrets.js
CHANGED
|
@@ -4,7 +4,7 @@ import { spawn } from "node:child_process";
|
|
|
4
4
|
import { writeSync } from "node:fs";
|
|
5
5
|
import * as nodePath from "node:path";
|
|
6
6
|
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
7
|
-
import { DEFAULT_SECRETS_CACHE_TTL_MS, writeCache, removeCacheEntry, clearAllCache, } from "../utils/secrets-cache.js";
|
|
7
|
+
import { DEFAULT_SECRETS_CACHE_TTL_MS, hasCacheEntry, readCache, writeCache, removeCacheEntry, clearAllCache, } from "../utils/secrets-cache.js";
|
|
8
8
|
import { computeSha256 } from "../utils/integrity.js";
|
|
9
9
|
import { SECRET_NAME_PATTERN, GROUP_ID_PATTERN, EMAIL_PATTERN } from "./_patterns.js";
|
|
10
10
|
import { describeSecretsScope, formatSecretSaved, formatSecretsListEmpty, formatSecretsListHeader, } from "./secrets-scope.js";
|
|
@@ -545,7 +545,12 @@ export async function loadRevealedSecrets(token, companyUid, keys, usage) {
|
|
|
545
545
|
}
|
|
546
546
|
const resolved = new Map();
|
|
547
547
|
const requested = [...new Set(keys)];
|
|
548
|
+
const cachedCandidates = new Set(requested.filter((name) => hasCacheEntry(companyUid, name)));
|
|
548
549
|
try {
|
|
550
|
+
// The batch endpoint is the only non-capturing authorization path that also
|
|
551
|
+
// evaluates the supplied usage metadata. It covers warm and cold names in
|
|
552
|
+
// bounded requests, then a warm name may use its locally authenticated
|
|
553
|
+
// plaintext only when the returned version and current policy TTL agree.
|
|
549
554
|
for (let i = 0; i < requested.length; i += MAX_BATCH_NAMES) {
|
|
550
555
|
const chunk = requested.slice(i, i + MAX_BATCH_NAMES);
|
|
551
556
|
const chunkNames = new Set(chunk);
|
|
@@ -572,6 +577,9 @@ export async function loadRevealedSecrets(token, companyUid, keys, usage) {
|
|
|
572
577
|
typeof body.code === "string") {
|
|
573
578
|
throw new Error(message);
|
|
574
579
|
}
|
|
580
|
+
if (res.status === 403) {
|
|
581
|
+
throw new Error(`Failed to fetch secret '${chunk[0]}': No read permission`);
|
|
582
|
+
}
|
|
575
583
|
throw new Error(`Failed to batch-load secrets: ${message}`);
|
|
576
584
|
}
|
|
577
585
|
const data = (await res.json());
|
|
@@ -601,8 +609,26 @@ export async function loadRevealedSecrets(token, companyUid, keys, usage) {
|
|
|
601
609
|
continue;
|
|
602
610
|
}
|
|
603
611
|
const cacheTtlMs = normalizeCacheTtlMs(s);
|
|
612
|
+
const cached = cachedCandidates.has(s.name) &&
|
|
613
|
+
cacheTtlMs > 0 &&
|
|
614
|
+
typeof s.version === "number" &&
|
|
615
|
+
Number.isSafeInteger(s.version) &&
|
|
616
|
+
s.version > 0
|
|
617
|
+
? readCache(companyUid, s.name, s.version, cacheTtlMs)
|
|
618
|
+
: null;
|
|
619
|
+
if (cached !== null) {
|
|
620
|
+
resolved.set(s.name, cached);
|
|
621
|
+
continue;
|
|
622
|
+
}
|
|
604
623
|
if (cacheTtlMs > 0) {
|
|
605
|
-
|
|
624
|
+
if (typeof s.version === "number" &&
|
|
625
|
+
Number.isSafeInteger(s.version) &&
|
|
626
|
+
s.version > 0) {
|
|
627
|
+
writeCache(companyUid, s.name, s.value, cacheTtlMs, s.version);
|
|
628
|
+
}
|
|
629
|
+
else {
|
|
630
|
+
writeCache(companyUid, s.name, s.value, cacheTtlMs);
|
|
631
|
+
}
|
|
606
632
|
}
|
|
607
633
|
else {
|
|
608
634
|
removeCacheEntry(companyUid, s.name);
|
package/dist/run/hq-plugin.js
CHANGED
|
@@ -181,7 +181,7 @@ export async function prewarmHqSecrets(graph /* EnvGraph */, opts, state) {
|
|
|
181
181
|
state.loadedSecretsByName.set(s.name, s.value);
|
|
182
182
|
const cacheTtlMs = normalizeCacheTtlMs(s);
|
|
183
183
|
if (cacheTtlMs > 0) {
|
|
184
|
-
writeCache(uid, s.name, s.value, cacheTtlMs);
|
|
184
|
+
writeCache(uid, s.name, s.value, cacheTtlMs, s.version);
|
|
185
185
|
}
|
|
186
186
|
else {
|
|
187
187
|
removeCacheEntry(uid, s.name);
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
export declare const DEFAULT_SECRETS_CACHE_TTL_MS: number;
|
|
2
|
-
export declare function readCache(companyUid: string, name: string): string | null;
|
|
3
|
-
|
|
2
|
+
export declare function readCache(companyUid: string, name: string, expectedVersion?: number, maxAgeMs?: number): string | null;
|
|
3
|
+
/**
|
|
4
|
+
* Returns whether an unexpired entry exists without decrypting its plaintext.
|
|
5
|
+
* Callers use this only to decide whether to reauthorize a possible cache hit;
|
|
6
|
+
* they must complete that authorization before calling {@link readCache}.
|
|
7
|
+
*/
|
|
8
|
+
export declare function hasCacheEntry(companyUid: string, name: string): boolean;
|
|
9
|
+
export declare function writeCache(companyUid: string, name: string, value: string, ttlMs?: number, version?: number): void;
|
|
4
10
|
/**
|
|
5
11
|
* List the scope UIDs (`cmp_*` / `prs_*` subdirectories) that currently have a
|
|
6
12
|
* secrets-cache directory on disk. Install-time MCP registration may use an
|
|
@@ -6,9 +6,13 @@ const CACHE_DIR = path.join(os.homedir(), ".hq", "secrets-cache");
|
|
|
6
6
|
const KEY_PATH = path.join(CACHE_DIR, ".key");
|
|
7
7
|
const CACHE_FORMAT_MAGIC = Buffer.from("HQSC");
|
|
8
8
|
const CACHE_FORMAT_MAGIC_BYTES = CACHE_FORMAT_MAGIC.length;
|
|
9
|
+
const CACHE_VERSION_MARKER = Buffer.from("HQSCV2\0");
|
|
10
|
+
const LEGACY_CACHE_VERSION_MARKER = Buffer.from("HQSCV1\0");
|
|
11
|
+
const CACHE_VERSION_MARKER_BYTES = CACHE_VERSION_MARKER.length;
|
|
9
12
|
const LEGACY_TIMESTAMP_BYTES = 8;
|
|
10
13
|
const TIMESTAMP_BYTES = 8;
|
|
11
14
|
const TTL_BYTES = 8;
|
|
15
|
+
const CACHE_VERSION_BYTES = 8;
|
|
12
16
|
const ALGORITHM = "aes-256-gcm";
|
|
13
17
|
const IV_BYTES = 12;
|
|
14
18
|
const AUTH_TAG_BYTES = 16;
|
|
@@ -45,7 +49,94 @@ function getOrCreateKey() {
|
|
|
45
49
|
fs.renameSync(tmpPath, KEY_PATH);
|
|
46
50
|
return fs.readFileSync(KEY_PATH);
|
|
47
51
|
}
|
|
48
|
-
|
|
52
|
+
function isCacheVersion(value) {
|
|
53
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
|
|
54
|
+
}
|
|
55
|
+
function readCacheVersion(raw, offset) {
|
|
56
|
+
const value = Number(raw.readBigInt64BE(offset));
|
|
57
|
+
if (value === 0)
|
|
58
|
+
return undefined;
|
|
59
|
+
return isCacheVersion(value) ? value : null;
|
|
60
|
+
}
|
|
61
|
+
function cacheAad(header, companyUid, name) {
|
|
62
|
+
const company = Buffer.from(companyUid, "utf8");
|
|
63
|
+
const secretName = Buffer.from(name, "utf8");
|
|
64
|
+
const companyLength = Buffer.alloc(4);
|
|
65
|
+
const nameLength = Buffer.alloc(4);
|
|
66
|
+
companyLength.writeUInt32BE(company.length);
|
|
67
|
+
nameLength.writeUInt32BE(secretName.length);
|
|
68
|
+
return Buffer.concat([
|
|
69
|
+
Buffer.from("HQSC-AAD-V2\0"),
|
|
70
|
+
header,
|
|
71
|
+
companyLength,
|
|
72
|
+
company,
|
|
73
|
+
nameLength,
|
|
74
|
+
secretName,
|
|
75
|
+
]);
|
|
76
|
+
}
|
|
77
|
+
function parseCacheHeader(raw) {
|
|
78
|
+
if (raw.subarray(0, CACHE_FORMAT_MAGIC_BYTES).equals(CACHE_FORMAT_MAGIC)) {
|
|
79
|
+
const baseHeaderBytes = CACHE_FORMAT_MAGIC_BYTES + TIMESTAMP_BYTES + TTL_BYTES;
|
|
80
|
+
if (raw.length < baseHeaderBytes + IV_BYTES + AUTH_TAG_BYTES)
|
|
81
|
+
return null;
|
|
82
|
+
let ivStart = baseHeaderBytes;
|
|
83
|
+
let version;
|
|
84
|
+
let identityAuthenticated = false;
|
|
85
|
+
if (raw.length >=
|
|
86
|
+
baseHeaderBytes + CACHE_VERSION_MARKER_BYTES + CACHE_VERSION_BYTES + IV_BYTES + AUTH_TAG_BYTES &&
|
|
87
|
+
raw
|
|
88
|
+
.subarray(baseHeaderBytes, baseHeaderBytes + CACHE_VERSION_MARKER_BYTES)
|
|
89
|
+
.equals(CACHE_VERSION_MARKER)) {
|
|
90
|
+
const parsedVersion = readCacheVersion(raw, baseHeaderBytes + CACHE_VERSION_MARKER_BYTES);
|
|
91
|
+
if (parsedVersion === null)
|
|
92
|
+
return null;
|
|
93
|
+
version = parsedVersion;
|
|
94
|
+
ivStart += CACHE_VERSION_MARKER_BYTES + CACHE_VERSION_BYTES;
|
|
95
|
+
identityAuthenticated = true;
|
|
96
|
+
}
|
|
97
|
+
else if (raw.length >=
|
|
98
|
+
baseHeaderBytes + LEGACY_CACHE_VERSION_MARKER.length + CACHE_VERSION_BYTES + IV_BYTES + AUTH_TAG_BYTES &&
|
|
99
|
+
raw
|
|
100
|
+
.subarray(baseHeaderBytes, baseHeaderBytes + LEGACY_CACHE_VERSION_MARKER.length)
|
|
101
|
+
.equals(LEGACY_CACHE_VERSION_MARKER)) {
|
|
102
|
+
const parsedVersion = readCacheVersion(raw, baseHeaderBytes + LEGACY_CACHE_VERSION_MARKER.length);
|
|
103
|
+
if (parsedVersion === null)
|
|
104
|
+
return null;
|
|
105
|
+
version = parsedVersion;
|
|
106
|
+
ivStart += LEGACY_CACHE_VERSION_MARKER.length + CACHE_VERSION_BYTES;
|
|
107
|
+
}
|
|
108
|
+
const authTagStart = ivStart + IV_BYTES;
|
|
109
|
+
const ciphertextStart = authTagStart + AUTH_TAG_BYTES;
|
|
110
|
+
if (raw.length < ciphertextStart)
|
|
111
|
+
return null;
|
|
112
|
+
return {
|
|
113
|
+
timestampMs: Number(raw.readBigInt64BE(CACHE_FORMAT_MAGIC_BYTES)),
|
|
114
|
+
ttlMs: Number(raw.readBigInt64BE(CACHE_FORMAT_MAGIC_BYTES + TIMESTAMP_BYTES)),
|
|
115
|
+
version,
|
|
116
|
+
identityAuthenticated,
|
|
117
|
+
ivStart,
|
|
118
|
+
authTagStart,
|
|
119
|
+
ciphertextStart,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
const headerLen = LEGACY_TIMESTAMP_BYTES + IV_BYTES + AUTH_TAG_BYTES;
|
|
123
|
+
if (raw.length < headerLen)
|
|
124
|
+
return null;
|
|
125
|
+
return {
|
|
126
|
+
timestampMs: Number(raw.readBigInt64BE(0)),
|
|
127
|
+
ttlMs: DEFAULT_SECRETS_CACHE_TTL_MS,
|
|
128
|
+
identityAuthenticated: false,
|
|
129
|
+
ivStart: LEGACY_TIMESTAMP_BYTES,
|
|
130
|
+
authTagStart: LEGACY_TIMESTAMP_BYTES + IV_BYTES,
|
|
131
|
+
ciphertextStart: headerLen,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
function isExpired(header, maxAgeMs) {
|
|
135
|
+
const ttlMs = maxAgeMs === undefined ? header.ttlMs : Math.min(header.ttlMs, maxAgeMs);
|
|
136
|
+
const ageMs = Date.now() - header.timestampMs;
|
|
137
|
+
return !Number.isFinite(ttlMs) || ttlMs <= 0 || ageMs < 0 || ageMs > ttlMs;
|
|
138
|
+
}
|
|
139
|
+
export function readCache(companyUid, name, expectedVersion, maxAgeMs) {
|
|
49
140
|
if (!validateInputs(companyUid, name))
|
|
50
141
|
return null;
|
|
51
142
|
const filePath = path.join(CACHE_DIR, companyUid, name);
|
|
@@ -56,40 +147,21 @@ export function readCache(companyUid, name) {
|
|
|
56
147
|
catch {
|
|
57
148
|
return null;
|
|
58
149
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
CACHE_FORMAT_MAGIC_BYTES + TIMESTAMP_BYTES + TTL_BYTES + IV_BYTES + AUTH_TAG_BYTES &&
|
|
66
|
-
raw.subarray(0, CACHE_FORMAT_MAGIC_BYTES).equals(CACHE_FORMAT_MAGIC)) {
|
|
67
|
-
timestampMs = Number(raw.readBigInt64BE(CACHE_FORMAT_MAGIC_BYTES));
|
|
68
|
-
ttlMs = Number(raw.readBigInt64BE(CACHE_FORMAT_MAGIC_BYTES + TIMESTAMP_BYTES));
|
|
69
|
-
ivStart = CACHE_FORMAT_MAGIC_BYTES + TIMESTAMP_BYTES + TTL_BYTES;
|
|
70
|
-
authTagStart = ivStart + IV_BYTES;
|
|
71
|
-
ciphertextStart = authTagStart + AUTH_TAG_BYTES;
|
|
72
|
-
}
|
|
73
|
-
else {
|
|
74
|
-
const headerLen = LEGACY_TIMESTAMP_BYTES + IV_BYTES + AUTH_TAG_BYTES;
|
|
75
|
-
if (raw.length < headerLen)
|
|
76
|
-
return null;
|
|
77
|
-
timestampMs = Number(raw.readBigInt64BE(0));
|
|
78
|
-
ttlMs = DEFAULT_SECRETS_CACHE_TTL_MS;
|
|
79
|
-
ivStart = LEGACY_TIMESTAMP_BYTES;
|
|
80
|
-
authTagStart = ivStart + IV_BYTES;
|
|
81
|
-
ciphertextStart = authTagStart + AUTH_TAG_BYTES;
|
|
82
|
-
}
|
|
83
|
-
if (ttlMs <= 0 || Date.now() - timestampMs > ttlMs) {
|
|
150
|
+
const header = parseCacheHeader(raw);
|
|
151
|
+
if (!header)
|
|
152
|
+
return null;
|
|
153
|
+
if (!header.identityAuthenticated ||
|
|
154
|
+
isExpired(header, maxAgeMs) ||
|
|
155
|
+
(expectedVersion !== undefined && header.version !== expectedVersion)) {
|
|
84
156
|
try {
|
|
85
157
|
fs.unlinkSync(filePath);
|
|
86
158
|
}
|
|
87
159
|
catch { /* ok */ }
|
|
88
160
|
return null;
|
|
89
161
|
}
|
|
90
|
-
const iv = raw.subarray(ivStart, authTagStart);
|
|
91
|
-
const authTag = raw.subarray(authTagStart, ciphertextStart);
|
|
92
|
-
const ciphertext = raw.subarray(ciphertextStart);
|
|
162
|
+
const iv = raw.subarray(header.ivStart, header.authTagStart);
|
|
163
|
+
const authTag = raw.subarray(header.authTagStart, header.ciphertextStart);
|
|
164
|
+
const ciphertext = raw.subarray(header.ciphertextStart);
|
|
93
165
|
let key;
|
|
94
166
|
try {
|
|
95
167
|
key = getOrCreateKey();
|
|
@@ -99,6 +171,7 @@ export function readCache(companyUid, name) {
|
|
|
99
171
|
}
|
|
100
172
|
try {
|
|
101
173
|
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
|
|
174
|
+
decipher.setAAD(cacheAad(raw.subarray(0, header.ivStart), companyUid, name));
|
|
102
175
|
decipher.setAuthTag(authTag);
|
|
103
176
|
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
104
177
|
return decrypted.toString("utf8");
|
|
@@ -111,7 +184,35 @@ export function readCache(companyUid, name) {
|
|
|
111
184
|
return null;
|
|
112
185
|
}
|
|
113
186
|
}
|
|
114
|
-
|
|
187
|
+
/**
|
|
188
|
+
* Returns whether an unexpired entry exists without decrypting its plaintext.
|
|
189
|
+
* Callers use this only to decide whether to reauthorize a possible cache hit;
|
|
190
|
+
* they must complete that authorization before calling {@link readCache}.
|
|
191
|
+
*/
|
|
192
|
+
export function hasCacheEntry(companyUid, name) {
|
|
193
|
+
if (!validateInputs(companyUid, name))
|
|
194
|
+
return false;
|
|
195
|
+
const filePath = path.join(CACHE_DIR, companyUid, name);
|
|
196
|
+
let raw;
|
|
197
|
+
try {
|
|
198
|
+
raw = fs.readFileSync(filePath);
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
const header = parseCacheHeader(raw);
|
|
204
|
+
if (!header)
|
|
205
|
+
return false;
|
|
206
|
+
if (!header.identityAuthenticated || isExpired(header)) {
|
|
207
|
+
try {
|
|
208
|
+
fs.unlinkSync(filePath);
|
|
209
|
+
}
|
|
210
|
+
catch { /* ok */ }
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
return true;
|
|
214
|
+
}
|
|
215
|
+
export function writeCache(companyUid, name, value, ttlMs = DEFAULT_SECRETS_CACHE_TTL_MS, version) {
|
|
115
216
|
try {
|
|
116
217
|
if (!validateInputs(companyUid, name))
|
|
117
218
|
return;
|
|
@@ -120,14 +221,29 @@ export function writeCache(companyUid, name, value, ttlMs = DEFAULT_SECRETS_CACH
|
|
|
120
221
|
ensureCacheDir(companyUid);
|
|
121
222
|
const key = getOrCreateKey();
|
|
122
223
|
const iv = crypto.randomBytes(IV_BYTES);
|
|
123
|
-
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
|
124
|
-
const encrypted = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
|
|
125
|
-
const authTag = cipher.getAuthTag();
|
|
126
224
|
const timestamp = Buffer.alloc(8);
|
|
127
225
|
timestamp.writeBigInt64BE(BigInt(Date.now()));
|
|
128
226
|
const ttl = Buffer.alloc(8);
|
|
129
227
|
ttl.writeBigInt64BE(BigInt(ttlMs));
|
|
130
|
-
const
|
|
228
|
+
const versionBuffer = Buffer.alloc(CACHE_VERSION_BYTES);
|
|
229
|
+
versionBuffer.writeBigInt64BE(BigInt(isCacheVersion(version) ? version : 0));
|
|
230
|
+
const header = Buffer.concat([
|
|
231
|
+
CACHE_FORMAT_MAGIC,
|
|
232
|
+
timestamp,
|
|
233
|
+
ttl,
|
|
234
|
+
CACHE_VERSION_MARKER,
|
|
235
|
+
versionBuffer,
|
|
236
|
+
]);
|
|
237
|
+
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
|
238
|
+
cipher.setAAD(cacheAad(header, companyUid, name));
|
|
239
|
+
const encrypted = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
|
|
240
|
+
const authTag = cipher.getAuthTag();
|
|
241
|
+
const out = Buffer.concat([
|
|
242
|
+
header,
|
|
243
|
+
iv,
|
|
244
|
+
authTag,
|
|
245
|
+
encrypted,
|
|
246
|
+
]);
|
|
131
247
|
const filePath = path.join(CACHE_DIR, companyUid, name);
|
|
132
248
|
fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
133
249
|
const tmpPath = `${filePath}.tmp.${process.pid}`;
|