passwd-sso-cli 0.4.45 → 0.4.46

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.
@@ -0,0 +1,65 @@
1
+ /**
2
+ * `passwd-sso audit-verify` — Verify an audit anchor manifest JWS.
3
+ *
4
+ * Validates the EdDSA signature, optionally checks tenant coverage,
5
+ * and optionally checks chain-sequence regression against a prior manifest.
6
+ */
7
+ export declare class InvalidAlgorithmError extends Error {
8
+ constructor(alg: unknown);
9
+ }
10
+ export declare class InvalidTypError extends Error {
11
+ constructor(typ: unknown);
12
+ }
13
+ export declare class InvalidSignatureError extends Error {
14
+ constructor();
15
+ }
16
+ export declare class InvalidTenantIdFormatError extends Error {
17
+ constructor(tenantId: unknown);
18
+ }
19
+ export declare class InvalidKidError extends Error {
20
+ constructor(kid: unknown);
21
+ }
22
+ export declare class ManifestSchemaValidationError extends Error {
23
+ constructor(detail: string);
24
+ }
25
+ export declare class InsecureTagSecretFileError extends Error {
26
+ constructor(path: string);
27
+ }
28
+ export declare class TenantNotInManifestError extends Error {
29
+ constructor(tag: string);
30
+ }
31
+ export declare class ChainBreakError extends Error {
32
+ constructor(expected: string, got: string);
33
+ }
34
+ export declare class ChainSeqRegressionError extends Error {
35
+ constructor(tenantTag: string, prior: string, current: string);
36
+ }
37
+ export declare class TagSecretRequiredError extends Error {
38
+ constructor();
39
+ }
40
+ export declare class InvalidTagSecretLengthError extends Error {
41
+ readonly actualBytes: number;
42
+ constructor(actualBytes: number);
43
+ }
44
+ export declare class PublicKeyArchiveUrlMissingError extends Error {
45
+ constructor();
46
+ }
47
+ export declare class PublicKeyFetchError extends Error {
48
+ readonly status: number;
49
+ constructor(status: number, url: string);
50
+ }
51
+ export declare class ManifestFetchError extends Error {
52
+ readonly status: number;
53
+ constructor(status: number);
54
+ }
55
+ export declare function computeTenantTag(tenantId: string, tagSecret: Buffer): string;
56
+ export type AuditVerifyArgs = {
57
+ manifest: string;
58
+ publicKey?: string;
59
+ myTenantId?: string;
60
+ tagSecret?: string;
61
+ tagSecretFile?: string;
62
+ priorManifest?: string;
63
+ archiveUrl?: string;
64
+ };
65
+ export declare function auditVerifyCommand(args: AuditVerifyArgs): Promise<void>;
@@ -0,0 +1,426 @@
1
+ /**
2
+ * `passwd-sso audit-verify` — Verify an audit anchor manifest JWS.
3
+ *
4
+ * Validates the EdDSA signature, optionally checks tenant coverage,
5
+ * and optionally checks chain-sequence regression against a prior manifest.
6
+ */
7
+ import { readFileSync, openSync, fstatSync, readSync, closeSync } from "node:fs";
8
+ import { createHash, createHmac, createPublicKey, verify as nodeVerify } from "node:crypto";
9
+ import { z } from "zod";
10
+ import { AUDIT_ANCHOR_KID_PREFIX, AUDIT_ANCHOR_TYP } from "../constants/audit-anchor.js";
11
+ // --- Constants ---
12
+ const AUDIT_ANCHOR_TAG_DOMAIN = "audit-anchor-tag-v1";
13
+ // --- Typed errors ---
14
+ export class InvalidAlgorithmError extends Error {
15
+ constructor(alg) {
16
+ super(`Invalid JWS alg: ${alg}; expected EdDSA`);
17
+ this.name = "InvalidAlgorithmError";
18
+ }
19
+ }
20
+ export class InvalidTypError extends Error {
21
+ constructor(typ) {
22
+ super(`Invalid JWS typ: ${typ}; expected ${AUDIT_ANCHOR_TYP}`);
23
+ this.name = "InvalidTypError";
24
+ }
25
+ }
26
+ export class InvalidSignatureError extends Error {
27
+ constructor() {
28
+ super("JWS signature verification failed");
29
+ this.name = "InvalidSignatureError";
30
+ }
31
+ }
32
+ export class InvalidTenantIdFormatError extends Error {
33
+ constructor(tenantId) {
34
+ super(`tenantId must be canonical lower-case UUID (RFC 4122 §3); got ${tenantId}`);
35
+ this.name = "InvalidTenantIdFormatError";
36
+ }
37
+ }
38
+ export class InvalidKidError extends Error {
39
+ constructor(kid) {
40
+ super(`Invalid kid format; expected ${AUDIT_ANCHOR_KID_PREFIX}<8-32 char> (no path separators): got ${kid}`);
41
+ this.name = "InvalidKidError";
42
+ }
43
+ }
44
+ export class ManifestSchemaValidationError extends Error {
45
+ constructor(detail) {
46
+ super(detail);
47
+ this.name = "ManifestSchemaValidationError";
48
+ }
49
+ }
50
+ export class InsecureTagSecretFileError extends Error {
51
+ constructor(path) {
52
+ super(`Tag-secret file ${path} must be mode 0600; found group/world-readable permissions`);
53
+ this.name = "InsecureTagSecretFileError";
54
+ }
55
+ }
56
+ export class TenantNotInManifestError extends Error {
57
+ constructor(tag) {
58
+ super(`Your tenant is not in this manifest (tenantTag mismatch): ${tag}`);
59
+ this.name = "TenantNotInManifestError";
60
+ }
61
+ }
62
+ export class ChainBreakError extends Error {
63
+ constructor(expected, got) {
64
+ super(`Prior manifest reference does not match supplied prior (chain break); expected=${expected}, got=${got}`);
65
+ this.name = "ChainBreakError";
66
+ }
67
+ }
68
+ export class ChainSeqRegressionError extends Error {
69
+ constructor(tenantTag, prior, current) {
70
+ super(`CHAIN_SEQ_REGRESSION at tenantTag=${tenantTag}: prior=${prior}, current=${current}`);
71
+ this.name = "ChainSeqRegressionError";
72
+ }
73
+ }
74
+ export class TagSecretRequiredError extends Error {
75
+ constructor() {
76
+ super("--my-tenant-id requires a tag secret (use --tag-secret-file, stdin, or --tag-secret)");
77
+ this.name = "TagSecretRequiredError";
78
+ }
79
+ }
80
+ export class InvalidTagSecretLengthError extends Error {
81
+ actualBytes;
82
+ constructor(actualBytes) {
83
+ super(`Tag secret must be exactly 32 bytes (64 hex chars); got ${actualBytes} bytes`);
84
+ this.name = "InvalidTagSecretLengthError";
85
+ this.actualBytes = actualBytes;
86
+ }
87
+ }
88
+ export class PublicKeyArchiveUrlMissingError extends Error {
89
+ constructor() {
90
+ super("AUDIT_ANCHOR_PUBLIC_KEY_ARCHIVE_URL must be set, or pass --public-key");
91
+ this.name = "PublicKeyArchiveUrlMissingError";
92
+ }
93
+ }
94
+ export class PublicKeyFetchError extends Error {
95
+ status;
96
+ constructor(status, url) {
97
+ super(`Failed to fetch public key: HTTP ${status} from ${url}`);
98
+ this.name = "PublicKeyFetchError";
99
+ this.status = status;
100
+ }
101
+ }
102
+ export class ManifestFetchError extends Error {
103
+ status;
104
+ constructor(status) {
105
+ super(`Failed to fetch manifest: HTTP ${status}`);
106
+ this.name = "ManifestFetchError";
107
+ this.status = status;
108
+ }
109
+ }
110
+ const tenantEntrySchema = z.object({
111
+ tenantTag: z.string().regex(/^[0-9a-f]{64}$/),
112
+ chainSeq: z.string().regex(/^(0|[1-9][0-9]*)$/),
113
+ prevHash: z.string().regex(/^([0-9a-f]{64}|[0-9a-f]{2})$/),
114
+ epoch: z.number().int().min(1),
115
+ });
116
+ const manifestSchema = z.object({
117
+ $schema: z.string(),
118
+ version: z.literal(1),
119
+ issuer: z.literal("passwd-sso"),
120
+ deploymentId: z.string(),
121
+ anchoredAt: z.string(),
122
+ previousManifest: z.object({ uri: z.string(), sha256: z.string() }).nullable(),
123
+ tenants: z.array(tenantEntrySchema),
124
+ });
125
+ // --- SubjectPublicKeyInfo prefix for Ed25519 (RFC 8410) ---
126
+ const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
127
+ // --- Helpers ---
128
+ function b64urlDecode(str) {
129
+ const padded = str + "=".repeat((4 - (str.length % 4)) % 4);
130
+ return Buffer.from(padded.replace(/-/g, "+").replace(/_/g, "/"), "base64");
131
+ }
132
+ function jcsCanonical(value) {
133
+ if (value === null)
134
+ return "null";
135
+ if (typeof value === "boolean")
136
+ return value ? "true" : "false";
137
+ if (typeof value === "number") {
138
+ if (!isFinite(value))
139
+ throw new Error("JCS: non-finite number");
140
+ return JSON.stringify(value);
141
+ }
142
+ if (typeof value === "string")
143
+ return JSON.stringify(value);
144
+ if (Array.isArray(value)) {
145
+ return "[" + value.map((v) => jcsCanonical(v)).join(",") + "]";
146
+ }
147
+ if (typeof value === "object") {
148
+ const keys = Object.keys(value).sort();
149
+ const entries = keys
150
+ .map((k) => {
151
+ const v = value[k];
152
+ if (v === undefined)
153
+ return null;
154
+ return JSON.stringify(k) + ":" + jcsCanonical(v);
155
+ })
156
+ .filter((e) => e !== null);
157
+ return "{" + entries.join(",") + "}";
158
+ }
159
+ throw new Error(`JCS: unsupported type ${typeof value}`);
160
+ }
161
+ function validateManifest(value) {
162
+ const result = manifestSchema.safeParse(value);
163
+ if (!result.success) {
164
+ const detail = JSON.stringify(result.error.issues);
165
+ throw new ManifestSchemaValidationError(detail);
166
+ }
167
+ return result.data;
168
+ }
169
+ function canonicalize(manifest) {
170
+ return Buffer.from(jcsCanonical(manifest), "utf-8");
171
+ }
172
+ function verifyJws(jws, publicKey) {
173
+ const parts = jws.split(".");
174
+ if (parts.length !== 3)
175
+ throw new InvalidSignatureError();
176
+ const [headerB64, payloadB64, sigB64] = parts;
177
+ let header;
178
+ try {
179
+ header = JSON.parse(b64urlDecode(headerB64).toString("utf-8"));
180
+ }
181
+ catch {
182
+ throw new InvalidSignatureError();
183
+ }
184
+ if (header.alg !== "EdDSA")
185
+ throw new InvalidAlgorithmError(header.alg);
186
+ if (header.typ !== AUDIT_ANCHOR_TYP)
187
+ throw new InvalidTypError(header.typ);
188
+ const keyObject = createPublicKey({
189
+ key: Buffer.concat([ED25519_SPKI_PREFIX, publicKey]),
190
+ format: "der",
191
+ type: "spki",
192
+ });
193
+ const signingInput = Buffer.from(`${headerB64}.${payloadB64}`, "utf-8");
194
+ const sigBytes = b64urlDecode(sigB64);
195
+ if (!nodeVerify(null, signingInput, keyObject, sigBytes)) {
196
+ throw new InvalidSignatureError();
197
+ }
198
+ let payloadObj;
199
+ try {
200
+ payloadObj = JSON.parse(b64urlDecode(payloadB64).toString("utf-8"));
201
+ }
202
+ catch {
203
+ throw new InvalidSignatureError();
204
+ }
205
+ const manifest = validateManifest(payloadObj);
206
+ // Re-canonicalize and compare to detect payload modifications
207
+ const recanon = canonicalize(manifest);
208
+ const payloadBytes = b64urlDecode(payloadB64);
209
+ if (!recanon.equals(payloadBytes)) {
210
+ throw new InvalidSignatureError();
211
+ }
212
+ return manifest;
213
+ }
214
+ function validateTenantIdCanonical(tenantId) {
215
+ const canonical = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
216
+ if (!canonical.test(tenantId)) {
217
+ throw new InvalidTenantIdFormatError(tenantId);
218
+ }
219
+ }
220
+ export function computeTenantTag(tenantId, tagSecret) {
221
+ validateTenantIdCanonical(tenantId);
222
+ const input = Buffer.concat([
223
+ Buffer.from(AUDIT_ANCHOR_TAG_DOMAIN, "utf-8"),
224
+ Buffer.from([0x00]),
225
+ Buffer.from(tenantId, "utf-8"),
226
+ ]);
227
+ return createHmac("sha256", tagSecret).update(input).digest("hex");
228
+ }
229
+ function extractKidFromJws(jws) {
230
+ const dot = jws.indexOf(".");
231
+ if (dot === -1)
232
+ throw new InvalidSignatureError();
233
+ const headerB64 = jws.slice(0, dot);
234
+ let header;
235
+ try {
236
+ header = JSON.parse(b64urlDecode(headerB64).toString("utf-8"));
237
+ }
238
+ catch {
239
+ throw new InvalidSignatureError();
240
+ }
241
+ return typeof header.kid === "string" ? header.kid : "";
242
+ }
243
+ function validateKid(kid) {
244
+ // Strict regex: only alphanumeric + hyphen + underscore — no path separators, percent-encoding, etc.
245
+ const kidPattern = new RegExp(`^${AUDIT_ANCHOR_KID_PREFIX}[a-zA-Z0-9_-]{8,32}$`);
246
+ if (!kidPattern.test(kid)) {
247
+ throw new InvalidKidError(kid);
248
+ }
249
+ }
250
+ function sha256Hex(buf) {
251
+ return createHash("sha256").update(buf).digest("hex");
252
+ }
253
+ // --- Secret redaction ---
254
+ function makeRedactedPrint(secret) {
255
+ return function printLine(msg, toStderr = false) {
256
+ const safe = secret && secret.length > 0 ? msg.split(secret).join("[REDACTED]") : msg;
257
+ if (toStderr) {
258
+ process.stderr.write(safe + "\n");
259
+ }
260
+ else {
261
+ process.stdout.write(safe + "\n");
262
+ }
263
+ };
264
+ }
265
+ // --- Main command ---
266
+ export async function auditVerifyCommand(args) {
267
+ // Determine tag secret (closes plan N9 / RT5)
268
+ let tagSecretHex;
269
+ if (args.tagSecretFile) {
270
+ // Open the file ONCE and use fstat + read on the same fd to prevent the
271
+ // TOCTOU race that CodeQL flagged: a separate statSync/readFileSync pair
272
+ // could see different inodes if an attacker swaps the file between calls.
273
+ // Same-fd ensures the mode bits we check belong to the bytes we read.
274
+ const fd = openSync(args.tagSecretFile, "r");
275
+ try {
276
+ const st = fstatSync(fd);
277
+ // Check no group/world bits (& 0o077 must be 0)
278
+ if ((st.mode & 0o077) !== 0) {
279
+ throw new InsecureTagSecretFileError(args.tagSecretFile);
280
+ }
281
+ // 64 hex chars + optional newline + a few bytes of slack — refuse anything
282
+ // larger to bound an attacker's ability to hand us a giant file.
283
+ const MAX_SECRET_BYTES = 128;
284
+ if (st.size > MAX_SECRET_BYTES) {
285
+ throw new InsecureTagSecretFileError(`${args.tagSecretFile} (file too large: ${st.size} bytes; expected <= ${MAX_SECRET_BYTES})`);
286
+ }
287
+ const buf = Buffer.alloc(st.size);
288
+ let read = 0;
289
+ while (read < st.size) {
290
+ const n = readSync(fd, buf, read, st.size - read, read);
291
+ if (n === 0)
292
+ break;
293
+ read += n;
294
+ }
295
+ tagSecretHex = buf.subarray(0, read).toString("utf-8").trim();
296
+ }
297
+ finally {
298
+ closeSync(fd);
299
+ }
300
+ }
301
+ else if (process.stdin.isTTY === false) {
302
+ // Read from stdin pipe (up to 64 hex chars + newline)
303
+ tagSecretHex = await readStdin(64);
304
+ tagSecretHex = tagSecretHex.trim();
305
+ }
306
+ else if (args.tagSecret) {
307
+ process.stderr.write("WARN: --tag-secret on the command line is recorded in shell history; prefer --tag-secret-file or stdin\n");
308
+ tagSecretHex = args.tagSecret;
309
+ }
310
+ const print = makeRedactedPrint(tagSecretHex);
311
+ // Fetch or read manifest JWS
312
+ let jws;
313
+ if (args.manifest.startsWith("http://") || args.manifest.startsWith("https://")) {
314
+ const resp = await fetch(args.manifest, { redirect: "manual" });
315
+ if (resp.status !== 200) {
316
+ throw new ManifestFetchError(resp.status);
317
+ }
318
+ jws = (await resp.text()).trim();
319
+ }
320
+ else {
321
+ jws = readFileSync(args.manifest, "utf-8").trim();
322
+ }
323
+ // Extract and validate kid (closes plan N7 / R3-N7 / RT5) — BEFORE any URL fetch
324
+ const kid = extractKidFromJws(jws);
325
+ validateKid(kid); // throws InvalidKidError if invalid
326
+ // Resolve public key
327
+ let publicKeyBytes;
328
+ if (args.publicKey) {
329
+ const raw = readFileSync(args.publicKey, "utf-8").trim();
330
+ publicKeyBytes = Buffer.from(raw, "hex");
331
+ }
332
+ else {
333
+ const archiveBase = args.archiveUrl ?? process.env["AUDIT_ANCHOR_PUBLIC_KEY_ARCHIVE_URL"];
334
+ if (!archiveBase) {
335
+ throw new PublicKeyArchiveUrlMissingError();
336
+ }
337
+ // Three layers of defense for the file-data → outbound-fetch flow:
338
+ // 1. validateKid() above restricts kid to ^audit-anchor-[a-zA-Z0-9_-]{8,32}$
339
+ // (no ".", "/", "%", or other path-special characters).
340
+ // 2. encodeURIComponent on kid — recognized by CodeQL as a sanitizer for
341
+ // URL-path-segment construction. At runtime this is a no-op because
342
+ // validateKid already restricted kid to characters that
343
+ // encodeURIComponent does not encode, but the call breaks the
344
+ // taint-flow CodeQL detects (see PR #419 review #4209883677).
345
+ // 3. URL constructor with archiveBaseUrl as base — resolution semantics
346
+ // pin the host to archiveBase's origin; combined with the explicit
347
+ // origin equality check below, kid cannot escape the archive origin.
348
+ const archiveBaseUrl = new URL(archiveBase);
349
+ const archivePath = archiveBaseUrl.pathname.replace(/\/$/, "");
350
+ const safeKid = encodeURIComponent(kid);
351
+ const pubUrlObj = new URL(`${archivePath}/${safeKid}.pub`, archiveBaseUrl);
352
+ if (pubUrlObj.origin !== archiveBaseUrl.origin) {
353
+ // Defense in depth: should be impossible given validateKid + URL semantics,
354
+ // but explicit reject if URL resolution somehow crosses origins.
355
+ throw new PublicKeyFetchError(0, pubUrlObj.href);
356
+ }
357
+ const resp = await fetch(pubUrlObj.href, { redirect: "manual" });
358
+ if (resp.status !== 200) {
359
+ throw new PublicKeyFetchError(resp.status, pubUrlObj.href);
360
+ }
361
+ const raw = (await resp.text()).trim();
362
+ publicKeyBytes = Buffer.from(raw, "hex");
363
+ }
364
+ // Verify JWS signature — throws typed errors on failure
365
+ const manifest = verifyJws(jws, publicKeyBytes);
366
+ // Optional tenant-coverage check
367
+ if (args.myTenantId) {
368
+ validateTenantIdCanonical(args.myTenantId); // throws InvalidTenantIdFormatError
369
+ if (!tagSecretHex) {
370
+ throw new TagSecretRequiredError();
371
+ }
372
+ const tagSecretBuf = Buffer.from(tagSecretHex, "hex");
373
+ if (tagSecretBuf.length !== 32)
374
+ throw new InvalidTagSecretLengthError(tagSecretBuf.length);
375
+ const tag = computeTenantTag(args.myTenantId, tagSecretBuf);
376
+ const entry = manifest.tenants.find((t) => t.tenantTag === tag);
377
+ if (!entry) {
378
+ throw new TenantNotInManifestError(tag);
379
+ }
380
+ print(`PASS — tenantTag=${tag}, chainSeq=${entry.chainSeq}, prevHash=${entry.prevHash}, epoch=${entry.epoch}`);
381
+ return;
382
+ }
383
+ // Optional prior-manifest regression check
384
+ if (args.priorManifest) {
385
+ if (manifest.previousManifest === null) {
386
+ process.stderr.write("WARN: current manifest claims genesis (no previousManifest) but --prior-manifest was supplied; chain link cannot be verified\n");
387
+ }
388
+ const priorJws = readFileSync(args.priorManifest, "utf-8").trim();
389
+ // Verify prior manifest signature too
390
+ const priorManifest = verifyJws(priorJws, publicKeyBytes);
391
+ // Check prior hash reference
392
+ const priorCanon = canonicalize(priorManifest);
393
+ const priorSha256 = sha256Hex(priorCanon);
394
+ if (manifest.previousManifest && manifest.previousManifest.sha256 !== priorSha256) {
395
+ throw new ChainBreakError(manifest.previousManifest.sha256, priorSha256);
396
+ }
397
+ // Check chainSeq regression per tenant
398
+ for (const priorTenant of priorManifest.tenants) {
399
+ const currentTenant = manifest.tenants.find((t) => t.tenantTag === priorTenant.tenantTag);
400
+ if (!currentTenant)
401
+ continue;
402
+ const priorSeq = BigInt(priorTenant.chainSeq);
403
+ const currentSeq = BigInt(currentTenant.chainSeq);
404
+ if (priorTenant.epoch === currentTenant.epoch && currentSeq < priorSeq) {
405
+ throw new ChainSeqRegressionError(priorTenant.tenantTag, priorTenant.chainSeq, currentTenant.chainSeq);
406
+ }
407
+ }
408
+ }
409
+ print("PASS");
410
+ }
411
+ async function readStdin(maxChars) {
412
+ return new Promise((resolve, reject) => {
413
+ let buf = "";
414
+ process.stdin.setEncoding("utf-8");
415
+ process.stdin.on("data", (chunk) => {
416
+ buf += chunk;
417
+ if (buf.length > maxChars + 2) {
418
+ // +2 for possible newline
419
+ buf = buf.slice(0, maxChars + 2);
420
+ }
421
+ });
422
+ process.stdin.on("end", () => resolve(buf));
423
+ process.stdin.on("error", reject);
424
+ });
425
+ }
426
+ //# sourceMappingURL=audit-verify.js.map
@@ -0,0 +1,2 @@
1
+ export declare const AUDIT_ANCHOR_KID_PREFIX = "audit-anchor-";
2
+ export declare const AUDIT_ANCHOR_TYP = "passwd-sso.audit-anchor.v1";
@@ -0,0 +1,4 @@
1
+ // Mirrors src/lib/constants/audit/audit.ts — drift caught by parity test.
2
+ export const AUDIT_ANCHOR_KID_PREFIX = "audit-anchor-";
3
+ export const AUDIT_ANCHOR_TYP = "passwd-sso.audit-anchor.v1";
4
+ //# sourceMappingURL=audit-anchor.js.map
package/dist/index.js CHANGED
@@ -21,6 +21,7 @@ import { runCommand } from "./commands/run.js";
21
21
  import { apiKeyListCommand, apiKeyCreateCommand, apiKeyRevokeCommand } from "./commands/api-key.js";
22
22
  import { agentCommand } from "./commands/agent.js";
23
23
  import { decryptCommand } from "./commands/decrypt.js";
24
+ import { auditVerifyCommand, InvalidAlgorithmError, InvalidTypError, InvalidSignatureError, ManifestSchemaValidationError, InvalidTenantIdFormatError, TenantNotInManifestError, ChainBreakError, ChainSeqRegressionError, InvalidTagSecretLengthError, } from "./commands/audit-verify.js";
24
25
  import { isUnlocked, lockVault } from "./lib/vault-state.js";
25
26
  import { clearPendingClipboard } from "./lib/clipboard.js";
26
27
  import { setInsecure, clearTokenCache, startBackgroundRefresh, stopBackgroundRefresh } from "./lib/api-client.js";
@@ -127,6 +128,53 @@ program
127
128
  .option("--field <field>", "Field to decrypt (default: password, ignored with --json)")
128
129
  .option("--json", "Output all decrypted fields as JSON (ignores --field)")
129
130
  .action((id, opts) => decryptCommand(id, { field: opts.field, mcpClient: opts.mcpClient, json: opts.json }));
131
+ program
132
+ .command("audit-verify")
133
+ .description("Verify an audit anchor manifest JWS (signature + optional tenant coverage)")
134
+ .requiredOption("-m, --manifest <path-or-url>", "Path to JWS file or URL")
135
+ .option("-p, --public-key <path>", "Path to .pub file (hex Ed25519); auto-fetched via kid if absent")
136
+ .option("--my-tenant-id <uuid>", "Your tenant UUID for chain-coverage check")
137
+ .option("--tag-secret-file <path>", "Path to file containing hex tag secret (recommended; must be mode 0600)")
138
+ .option("--tag-secret <hex>", "Hex tag secret on the CLI (discouraged — recorded in shell history)")
139
+ .option("--prior-manifest <path>", "Prior manifest JWS file for regression check")
140
+ .option("--archive-url <url>", "Override AUDIT_ANCHOR_PUBLIC_KEY_ARCHIVE_URL for public key fetch")
141
+ .action(async (opts) => {
142
+ try {
143
+ await auditVerifyCommand({
144
+ manifest: opts.manifest,
145
+ publicKey: opts.publicKey,
146
+ myTenantId: opts.myTenantId,
147
+ tagSecret: opts.tagSecret,
148
+ tagSecretFile: opts.tagSecretFile,
149
+ priorManifest: opts.priorManifest,
150
+ archiveUrl: opts.archiveUrl,
151
+ });
152
+ }
153
+ catch (err) {
154
+ const msg = (err instanceof Error ? err.message : String(err)) + "\n";
155
+ process.stderr.write(msg);
156
+ if (err instanceof InvalidAlgorithmError)
157
+ process.exit(10);
158
+ else if (err instanceof InvalidTypError)
159
+ process.exit(11);
160
+ else if (err instanceof InvalidSignatureError)
161
+ process.exit(12);
162
+ else if (err instanceof ManifestSchemaValidationError)
163
+ process.exit(13);
164
+ else if (err instanceof InvalidTenantIdFormatError)
165
+ process.exit(14);
166
+ else if (err instanceof TenantNotInManifestError)
167
+ process.exit(15);
168
+ else if (err instanceof ChainBreakError)
169
+ process.exit(16);
170
+ else if (err instanceof ChainSeqRegressionError)
171
+ process.exit(17);
172
+ else if (err instanceof InvalidTagSecretLengthError)
173
+ process.exit(18);
174
+ else
175
+ process.exit(1);
176
+ }
177
+ });
130
178
  program.parse();
131
179
  // ─── Interactive REPL Mode ──────────────────────────────────────
132
180
  async function interactiveMode() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "passwd-sso-cli",
3
- "version": "0.4.45",
3
+ "version": "0.4.46",
4
4
  "description": "CLI for passwd-sso password manager",
5
5
  "type": "module",
6
6
  "license": "MIT",