@indigoai-us/hq-cli 5.107.0 → 5.108.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.
@@ -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
- export function readCache(companyUid, name) {
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
- let timestampMs;
60
- let ttlMs;
61
- let ivStart;
62
- let authTagStart;
63
- let ciphertextStart;
64
- if (raw.length >=
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
- export function writeCache(companyUid, name, value, ttlMs = DEFAULT_SECRETS_CACHE_TTL_MS) {
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 out = Buffer.concat([CACHE_FORMAT_MAGIC, timestamp, ttl, iv, authTag, encrypted]);
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}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.107.0",
3
+ "version": "5.108.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -31,6 +31,7 @@
31
31
  "@aws-sdk/client-iot-data-plane": "^3.1096.0",
32
32
  "@aws-sdk/client-s3": "^3.1049.0",
33
33
  "@indigoai-us/hq-cloud": "~6.16.6",
34
+ "@indigoai-us/hq-flags-client": "^0.1.2",
34
35
  "@indigoai-us/hq-onboarding": "^0.1.0",
35
36
  "@sentry/node": "^10.49.0",
36
37
  "@tobilu/qmd": "2.5.3",