From dca379156847a322a01ebc072269c3897a3ed49d Mon Sep 17 00:00:00 2001 From: Matt Wilkinson Date: Wed, 8 Jul 2026 19:11:57 -0400 Subject: [PATCH 1/4] fix(cli): cotal mint reuses the existing identity unless --force MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mint.ts unconditionally called newIdentity() on every mint, so re-minting an agent (e.g. to refresh its channels from the persona file) rotated the mesh id. The durable ACL row and the dm/dlv delivery durables are all keyed by the nkey public key, so rotation orphaned them → the agent went @mention-wake-blind with 'consumer not found' until re-provisioned (hit live 6x during a fleet relaunch). Option B (maintainer ruling on design PR #3, cubic P1): re-mint reuses the same identity. New core helper identityFromCreds(creds): Identity — the id-preserving sibling of idFromCreds (which it reuses for the id + JWT-subject cross-check), returning { id, seed } from the creds' seed block. mint computes the out path before minting; if a creds file exists there and --force is not passed, it re-signs that SAME id+seed with fresh ACLs; otherwise it mints a new identity. --force keeps the rotation escape hatch (compromised key / deliberate new id). Orthogonal to the still-open mint-strategy fork (#3): changes WHICH id mint uses, not WHERE the ACL write triggers. Pairs with the PR #4 DLV-durable fix. Refs #3. Co-Authored-By: seal --- implementations/cli/src/commands/mint.ts | 19 ++++++++++++++----- packages/core/src/identity.ts | 12 ++++++++++++ 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/implementations/cli/src/commands/mint.ts b/implementations/cli/src/commands/mint.ts index 474e51d4..ca568098 100644 --- a/implementations/cli/src/commands/mint.ts +++ b/implementations/cli/src/commands/mint.ts @@ -1,8 +1,9 @@ -import { existsSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { resolve, join, dirname } from "node:path"; import { parseArgs } from "node:util"; import { agentFilePath, + identityFromCreds, loadAgentFile, mintCreds, mkSecretDir, @@ -28,7 +29,7 @@ export async function mint(argv: string[]): Promise { profile: { type: "string" }, out: { type: "string" }, signer: { type: "boolean" }, // emit a stripped signer file instead of agent/observer creds - force: { type: "boolean" }, // (--signer) overwrite an existing signer file + force: { type: "boolean" }, // overwrite an existing --signer file; or (agent) rotate to a fresh id instead of reusing the existing creds' "allow-subscribe": { type: "string" }, // read ACL override (comma-separated) "allow-publish": { type: "string" }, // post ACL override (comma-separated) }, @@ -85,12 +86,20 @@ export async function mint(argv: string[]): Promise { allowPublish = splitList(values["allow-publish"]) ?? def?.allowPublish; role = def?.role; } - const identity = newIdentity(); - const creds = await mintCreds(auth, identity, profile, { allowSubscribe, allowPublish, role }); + // Re-mint reuses the SAME identity by default. mint's read/post ACLs come from the persona file, + // so re-minting is how an agent's channels get refreshed — but the mesh id, its durable ACL row, + // and its dm/dlv durables are all keyed by the nkey public key. Rotating the id on every mint + // (the old behavior) orphaned that row + those durables, leaving the agent @mention-wake-blind + // until re-provisioned. So: if a creds file already exists here, re-sign its SAME id+seed with the + // fresh ACLs; only mint a brand-new identity when there's no creds yet, or --force rotates + // deliberately (a compromised key / intentional new identity). const out = resolve(values.out ?? join(dir, "creds", `${name}.creds`)); + const reuse = !values.force && existsSync(out); + const identity = reuse ? identityFromCreds(readFileSync(out, "utf8")) : newIdentity(); + const creds = await mintCreds(auth, identity, profile, { allowSubscribe, allowPublish, role }); mkSecretDir(dirname(out)); writeSecretFile(out, creds); console.log(c.green(`✓ minted ${profile} creds for "${name}"`)); - console.log(c.dim(` id: ${identity.id}`)); + console.log(c.dim(` id: ${identity.id}${reuse ? " (reused — re-mint kept the identity)" : " (new)"}`)); console.log(c.dim(` creds: ${out}`)); } diff --git a/packages/core/src/identity.ts b/packages/core/src/identity.ts index 86e16ffd..14b2b895 100644 --- a/packages/core/src/identity.ts +++ b/packages/core/src/identity.ts @@ -42,3 +42,15 @@ export function idFromCreds(creds: string): string { if (sub && sub !== id) throw new Error(`creds: seed identity ${id} != JWT subject ${sub}`); return id; } + +/** The full identity (id + seed) carried by a creds file — the id-preserving sibling of + * {@link idFromCreds}. Re-mint reads this to RE-SIGN the SAME identity (stable id) with refreshed + * ACLs instead of rotating to a fresh nkey, so the agent's durable ACL row and dm/dlv durables (all + * keyed by id) stay valid across a re-mint. `idFromCreds` supplies the id AND its JWT-subject + * cross-check (a spliced seed+JWT throws there); the seed is the same block it validates, returned + * raw for {@link mintCreds} to re-embed. */ +export function identityFromCreds(creds: string): Identity { + const seedM = creds.match(/BEGIN USER NKEY SEED-----\s*([\s\S]*?)\s*------END USER NKEY SEED/); + if (!seedM) throw new Error("creds: no user nkey seed block found"); + return { id: idFromCreds(creds), seed: seedM[1].trim() }; +} From 4161ddd7cf3477b0e19e0930cba6c88090d3b47b Mon Sep 17 00:00:00 2001 From: Matt Wilkinson Date: Wed, 8 Jul 2026 19:31:23 -0400 Subject: [PATCH 2/4] fix(cli): gate mint id-reuse to the agent profile + fail loud on unparseable creds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review findings on the reuse logic (cubic P1 + greptile P2 on #7): - Reuse now requires profile === "agent". The durable-ACL-orphan rationale is agent-specific (persona-refresh workflow + dm/dlv durables keyed to the id); observer/admin creds have neither, and silently preserving a privileged admin key across re-mints would extend its lifetime unexpectedly. Observer/admin now always rotate, as before. - A present-but-unparseable creds file (empty, truncated, not a user creds file) now fails loud with an actionable error naming --force, instead of letting identityFromCreds throw a raw parse exception. Silently rotating there would orphan the durable row the existing id may still own, so fresh-mint is not a safe fallback — the operator must opt in via --force or remove the stale file. Verified: agent re-mint keeps id; admin/observer re-mint rotate; corrupt creds at the out path errors naming --force (no raw crash, no silent rotate). Refs #3. Co-Authored-By: seal --- implementations/cli/src/commands/mint.ts | 37 ++++++++++++++++++------ 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/implementations/cli/src/commands/mint.ts b/implementations/cli/src/commands/mint.ts index ca568098..6c6292bb 100644 --- a/implementations/cli/src/commands/mint.ts +++ b/implementations/cli/src/commands/mint.ts @@ -10,6 +10,7 @@ import { newIdentity, stripSpaceAuth, writeSecretFile, + type Identity, type Profile, } from "@cotal-ai/core"; import { authDir, loadSpaceAuth } from "@cotal-ai/workspace"; @@ -86,16 +87,34 @@ export async function mint(argv: string[]): Promise { allowPublish = splitList(values["allow-publish"]) ?? def?.allowPublish; role = def?.role; } - // Re-mint reuses the SAME identity by default. mint's read/post ACLs come from the persona file, - // so re-minting is how an agent's channels get refreshed — but the mesh id, its durable ACL row, - // and its dm/dlv durables are all keyed by the nkey public key. Rotating the id on every mint - // (the old behavior) orphaned that row + those durables, leaving the agent @mention-wake-blind - // until re-provisioned. So: if a creds file already exists here, re-sign its SAME id+seed with the - // fresh ACLs; only mint a brand-new identity when there's no creds yet, or --force rotates - // deliberately (a compromised key / intentional new identity). + // Re-mint reuses the SAME identity by default — but only for the `agent` profile. mint's read/post + // ACLs come from the persona file, so re-minting is how an agent's channels get refreshed; and the + // mesh id, its durable ACL row, and its dm/dlv durables are all keyed by the nkey public key, so + // rotating the id on every mint (the old behavior) orphaned that row + those durables, leaving the + // agent @mention-wake-blind until re-provisioned. Observer/admin creds carry no persona-refresh + // workflow and no durable footprint to orphan, and silently extending a privileged admin key's + // lifetime across re-mints would be surprising — so they always rotate. Reuse only when: agent + // profile, a creds file already exists here, and --force did not ask for deliberate rotation + // (a compromised key / intentional new identity). const out = resolve(values.out ?? join(dir, "creds", `${name}.creds`)); - const reuse = !values.force && existsSync(out); - const identity = reuse ? identityFromCreds(readFileSync(out, "utf8")) : newIdentity(); + const reuse = profile === "agent" && !values.force && existsSync(out); + let identity: Identity; + if (reuse) { + try { + identity = identityFromCreds(readFileSync(out, "utf8")); + } catch (e) { + // A present-but-unreadable creds file (empty, truncated, or not a user creds file) must fail + // loud, not silently rotate — silently minting a fresh id here would orphan the durable row + // the existing id may still own. Point the operator at the deliberate-rotation escape hatch. + throw new Error( + `cotal mint: creds already exist at ${out} but could not be parsed to reuse the identity ` + + `(${e instanceof Error ? e.message : String(e)}). Pass --force to mint a fresh identity ` + + `(rotates the id), or remove the file if it is stale.`, + ); + } + } else { + identity = newIdentity(); + } const creds = await mintCreds(auth, identity, profile, { allowSubscribe, allowPublish, role }); mkSecretDir(dirname(out)); writeSecretFile(out, creds); From bd3edb6d420cac961be55dd60f9abc9b3b45c6aa Mon Sep 17 00:00:00 2001 From: Matt Wilkinson Date: Wed, 8 Jul 2026 19:49:55 -0400 Subject: [PATCH 3/4] test(cli): red-green regression for mint identity-reuse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layer 1 — packages/core/smoke/identity.smoke.ts (offline, 4 asserts): identityFromCreds round-trips {id, seed} unchanged; agrees with idFromCreds (one-id-everywhere); rejects spliced creds (seed vs foreign JWT subject) and a missing seed block. Layer 2 — implementations/cli/smoke/mint-reuse.smoke.ts (hermetic, exercises the real mint() against a tmp .cotal root, 8 checks): re-minting an agent reuses the SAME id; --force rotates; a persona ACL change refreshes the baked sub.allow WITHOUT rotating the id; first mint of a new name mints fresh (absent-creds path, no crash); observer + admin re-mint rotate (reuse is agent-only); an unparseable existing creds file fails loud naming --force (no silent rotate, no raw crash). Red-green verified: reverting the reuse block to unconditional newIdentity() fails checks 1, 3, and 6 (two mints → two ids; the exact #3 cubic-P1 durable-orphan bug); the fix turns them green. Registered both as smoke:identity + smoke:mint-reuse and wired into smoke:ci. Refs #3. Co-Authored-By: seal --- implementations/cli/smoke/mint-reuse.smoke.ts | 134 ++++++++++++++++++ package.json | 4 +- packages/core/smoke/identity.smoke.ts | 51 +++++++ 3 files changed, 188 insertions(+), 1 deletion(-) create mode 100644 implementations/cli/smoke/mint-reuse.smoke.ts create mode 100644 packages/core/smoke/identity.smoke.ts diff --git a/implementations/cli/smoke/mint-reuse.smoke.ts b/implementations/cli/smoke/mint-reuse.smoke.ts new file mode 100644 index 00000000..d6beb985 --- /dev/null +++ b/implementations/cli/smoke/mint-reuse.smoke.ts @@ -0,0 +1,134 @@ +/** + * `cotal mint` identity-reuse smoke (hermetic — no broker). Exercises the REAL mint() command against + * a tmp `.cotal/` root laid out exactly as `cotal up` writes it (auth.json via saveSpaceAuth + a + * persona file), and asserts the reuse-unless-force contract end to end. Run with: + * pnpm --filter @cotal-ai/cli exec tsx smoke/mint-reuse.smoke.ts + * + * The load-bearing regression: re-minting an AGENT keeps the SAME nkey id (so its durable ACL row + + * dm/dlv durables, all id-keyed, stay valid) — the old unconditional newIdentity() rotated the id on + * every mint and orphaned them, leaving the agent @mention-wake-blind. --force rotates deliberately; + * a persona ACL change refreshes the baked channels WITHOUT rotating; observer/admin always rotate + * (reuse is agent-only — no durable footprint to orphan, and re-signing a privileged key would + * silently extend its lifetime); and a present-but-unparseable creds file fails loud (never a silent + * fresh-mint that would orphan the id its predecessor may still own). + * + * mint() resolves its root via findCotalRoot() walking up from process.cwd(), so the harness chdir's + * into the tmp root and restores cwd in the finally. + */ +import { strict as assert } from "node:assert"; +import { randomUUID } from "node:crypto"; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createSpaceAuth, idFromCreds } from "@cotal-ai/core"; +import { authDir, saveSpaceAuth } from "@cotal-ai/workspace"; +import { mint } from "../src/commands/mint.js"; + +let failures = 0; +function check(label: string, cond: boolean, extra?: unknown): void { + console.log(`${cond ? "✓" : "✗"} ${label}${cond ? "" : ` — ${JSON.stringify(extra)}`}`); + if (!cond) failures++; +} + +// The chat-read channels a minted creds file grants (decode the JWT's nats.sub.allow, keep chat.*. +// entries). Same JWT-decode shape as manager/smoke/persona-identity-acl.smoke.ts. +function credSubChat(path: string): string[] { + const jwt = readFileSync(path, "utf8").split("\n").find((l) => l && !l.startsWith("-") && l.split(".").length === 3)!; + const claims = JSON.parse(Buffer.from(jwt.split(".")[1], "base64url").toString("utf8")); + const allow: string[] = claims.nats?.sub?.allow ?? []; + return allow.filter((s) => s.includes(".chat.")); +} + +// mint() logs its result to stdout; silence it (restored even on throw) so the check output stays +// legible — the ids we assert on are read straight from the written creds file, not from the log. +async function mintQuiet(argv: string[]): Promise { + const orig = console.log; + console.log = () => {}; + try { + await mint(argv); + } finally { + console.log = orig; + } +} + +// A tmp `.cotal/` root with real space trust material (auth.json, exactly as `cotal up` persists it) +// and a `scout` agent persona reading/posting the `general` channel. +const space = `mint-reuse-${randomUUID().slice(0, 8)}`; +const auth = await createSpaceAuth(space); +const root = mkdtempSync(join(tmpdir(), "cotal-mint-reuse-")); +const agentsDir = join(root, ".cotal", "agents"); +mkdirSync(agentsDir, { recursive: true }); +saveSpaceAuth(authDir(root), auth); +const scoutPersona = join(agentsDir, "scout.md"); +writeFileSync(scoutPersona, "---\nname: scout\nsubscribe: [general]\nallowSubscribe: [general]\nallowPublish: [general]\n---\nbody\n"); +const scoutCreds = join(authDir(root), "creds", "scout.creds"); +const credsDir = join(authDir(root), "creds"); + +const prevCwd = process.cwd(); +process.chdir(root); // findCotalRoot() walks up from cwd — anchor it at our tmp root +try { + // 1) THE regression: re-minting an agent twice reuses the SAME id (before the fix, two mints gave + // two different ids and orphaned the id-keyed durables). + await mintQuiet(["scout"]); + const id1 = idFromCreds(readFileSync(scoutCreds, "utf8")); + await mintQuiet(["scout"]); + const id2 = idFromCreds(readFileSync(scoutCreds, "utf8")); + check("re-mint of an agent reuses the same id", id1 === id2, { id1, id2 }); + + // 2) --force is the deliberate-rotation escape hatch: a fresh id despite an existing creds file. + await mintQuiet(["scout", "--force"]); + const id3 = idFromCreds(readFileSync(scoutCreds, "utf8")); + check("mint --force rotates to a different id", id3 !== id2, { id2, id3 }); + + // 3) A persona ACL change is applied on re-mint (refreshed channels) WITHOUT rotating the id — + // the whole point: an agent's read scope is refreshed while its mesh id stays stable. + const beforeChans = credSubChat(scoutCreds); // channels baked before the persona change + writeFileSync(scoutPersona, "---\nname: scout\nsubscribe: [general]\nallowSubscribe: [general, review]\nallowPublish: [general]\n---\nbody\n"); + await mintQuiet(["scout"]); + const id4 = idFromCreds(readFileSync(scoutCreds, "utf8")); + const afterChans = credSubChat(scoutCreds); + check("re-mint after an ACL change keeps the identity", id4 === id3, { id3, id4 }); + check( + "re-mint bakes the NEW channel into sub.allow (review added, was absent before)", + afterChans.some((s) => s.endsWith(".chat.*.review")) && !beforeChans.some((s) => s.endsWith(".chat.*.review")), + { beforeChans, afterChans }, + ); + + // 4) First-ever mint of a brand-new name has no creds file — the reuse=false path must mint a fresh + // identity and write valid creds, not crash on the absent-file read. + writeFileSync(join(agentsDir, "newbie.md"), "---\nname: newbie\nsubscribe: [general]\nallowSubscribe: [general]\n---\nbody\n"); + const newbieCreds = join(credsDir, "newbie.creds"); + await mintQuiet(["newbie"]); + const idNew = idFromCreds(readFileSync(newbieCreds, "utf8")); + check("first mint of a brand-new name mints a fresh identity (absent-creds path, no crash)", idNew !== id4 && idNew.startsWith("U"), { idNew, id4 }); + + // 5) Reuse is AGENT-ONLY: re-minting an observer or admin creds file ROTATES the id (a privileged + // key must not silently extend its lifetime across re-mints). + for (const profile of ["observer", "admin"] as const) { + const pOut = join(credsDir, `${profile}-dash.creds`); + await mintQuiet([`${profile}-dash`, "--profile", profile]); + const a = idFromCreds(readFileSync(pOut, "utf8")); + await mintQuiet([`${profile}-dash`, "--profile", profile]); + const b = idFromCreds(readFileSync(pOut, "utf8")); + check(`re-mint of an ${profile} creds rotates the id (reuse is agent-only)`, a !== b, { profile, a, b }); + } + + // 6) A present-but-unparseable creds file at the out path fails LOUD naming --force — never a raw + // parse crash, and never a silent fresh-mint (which would orphan the predecessor id's durables). + mkdirSync(credsDir, { recursive: true }); + writeFileSync(join(credsDir, "corrupt.creds"), ""); // present but no seed block + let msg = ""; + try { + await mintQuiet(["corrupt"]); + } catch (e) { + msg = e instanceof Error ? e.message : String(e); + } + check("unparseable existing creds → actionable error naming --force (no silent rotate, no raw crash)", /could not be parsed/.test(msg) && /--force/.test(msg), { msg }); +} finally { + process.chdir(prevCwd); + rmSync(root, { recursive: true, force: true }); +} + +console.log(`\nmint-reuse smoke: ${failures === 0 ? "OK ✅" : "FAILED ❌"} (${failures} failing)`); +assert.equal(failures, 0, `${failures} check(s) failed`); +process.exit(0); diff --git a/package.json b/package.json index 30071edc..7d81818f 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,9 @@ "gen:schema": "node scripts/generate-cotal-schema.mjs", "test": "pnpm -r --if-present test", "check": "pnpm typecheck && pnpm test && pnpm smoke:view && pnpm smoke:spawn-from-anywhere && pnpm smoke:spawn-from-anywhere:live && pnpm smoke:connect && pnpm smoke:core-boundary && pnpm smoke:preflight && pnpm smoke:ci", - "smoke:ci": "pnpm smoke:read-acl:auth && pnpm smoke:sub-acl:auth && pnpm smoke:self-serve-join:auth && pnpm smoke:control-auth && pnpm smoke:manager-split && pnpm smoke:deprovision && pnpm smoke:control-reply-bound && pnpm smoke:channels:auth && pnpm smoke:e2e:acl && pnpm smoke:plane3:auth && pnpm smoke:delivery-lease:auth && pnpm smoke:delivery-leave-tombstone:auth && pnpm smoke:delivery-reconnect:auth && pnpm smoke:delivery-cred:auth && pnpm smoke:delivery-reply-injection:auth && pnpm smoke:membership:auth && pnpm smoke:membership-feed-confinement:auth && pnpm smoke:wildcard-backfill && pnpm smoke:opencode && pnpm smoke:opencode-coop && pnpm smoke:opencode-transcript && pnpm smoke:transcript-grant && pnpm smoke:persona-acl", + "smoke:ci": "pnpm smoke:read-acl:auth && pnpm smoke:sub-acl:auth && pnpm smoke:self-serve-join:auth && pnpm smoke:control-auth && pnpm smoke:manager-split && pnpm smoke:deprovision && pnpm smoke:control-reply-bound && pnpm smoke:channels:auth && pnpm smoke:e2e:acl && pnpm smoke:plane3:auth && pnpm smoke:delivery-lease:auth && pnpm smoke:delivery-leave-tombstone:auth && pnpm smoke:delivery-reconnect:auth && pnpm smoke:delivery-cred:auth && pnpm smoke:delivery-reply-injection:auth && pnpm smoke:membership:auth && pnpm smoke:membership-feed-confinement:auth && pnpm smoke:wildcard-backfill && pnpm smoke:opencode && pnpm smoke:opencode-coop && pnpm smoke:opencode-transcript && pnpm smoke:transcript-grant && pnpm smoke:persona-acl && pnpm smoke:identity && pnpm smoke:mint-reuse", + "smoke:identity": "tsx packages/core/smoke/identity.smoke.ts", + "smoke:mint-reuse": "tsx implementations/cli/smoke/mint-reuse.smoke.ts", "clean:dry": "git clean -ndX -- node_modules .pnpm-store packages extensions implementations examples remotion", "clean": "git clean -fdX -- node_modules .pnpm-store packages extensions implementations examples remotion", "cotal": "tsx bin/cotal.ts", diff --git a/packages/core/smoke/identity.smoke.ts b/packages/core/smoke/identity.smoke.ts new file mode 100644 index 00000000..7004e6ac --- /dev/null +++ b/packages/core/smoke/identity.smoke.ts @@ -0,0 +1,51 @@ +/** + * identityFromCreds unit smoke (pure, no broker) — run with: + * pnpm --filter @cotal-ai/core exec tsx smoke/identity.smoke.ts + * + * Defends the id-preserving creds reader that `cotal mint` uses to REUSE an agent's identity across a + * re-mint (the reuse-unless-force fix). The contract: the {id, seed} carried by a creds file round-trips + * unchanged; the id agrees with idFromCreds; and a creds file that is corrupt (no seed block) or spliced + * (a seed paired with a foreign JWT subject) is REJECTED — never silently handing back a wrong or + * mismatched id, which is exactly what would let a re-mint re-sign the wrong identity. + */ +import assert from "node:assert/strict"; +import { createSpaceAuth, mintCreds } from "../src/provision.js"; +import { newIdentity, idFromCreds, identityFromCreds } from "../src/identity.js"; + +// Offline key material — createSpaceAuth mints an operator→account chain locally, no broker needed. +const auth = await createSpaceAuth("test"); + +// (a) Round-trip: a freshly-minted agent creds file yields back the SAME id AND seed (both fields). +// This is the load-bearing property — reuse re-signs THIS identity, so a wrong id or a mangled +// seed here would silently rotate the agent despite the "reuse" intent. +{ + const id = newIdentity(); + const creds = await mintCreds(auth, id, "agent", { allowSubscribe: ["general"] }); + const got = identityFromCreds(creds); + assert.equal(got.id, id.id, "identityFromCreds must recover the minted id"); + assert.equal(got.seed, id.seed, "identityFromCreds must recover the minted seed verbatim"); +} + +// (b) Single-id cross-consistency at the API boundary: the id it returns agrees with idFromCreds on +// the same creds (the "one id everywhere" invariant — the two readers must never diverge). +{ + const creds = await mintCreds(auth, newIdentity(), "agent", { allowSubscribe: ["general"] }); + assert.equal(identityFromCreds(creds).id, idFromCreds(creds)); +} + +// (c) Spliced creds (A's seed + B's JWT ⇒ JWT subject ≠ seed identity) is REJECTED. identityFromCreds +// inherits idFromCreds's JWT-subject cross-check, so it can't return a seed whose JWT claims a +// different identity — the guard against re-signing a seed that was paired with someone else's JWT. +{ + const a = await mintCreds(auth, newIdentity(), "agent", { allowSubscribe: ["general"] }); + const b = await mintCreds(auth, newIdentity(), "agent", { allowSubscribe: ["general"] }); + const jwtBlock = /-----BEGIN NATS USER JWT-----[\s\S]*?------END NATS USER JWT------/; + const bJwt = b.match(jwtBlock)![0]; + const spliced = a.replace(jwtBlock, bJwt); // A's seed block, B's JWT subject + assert.throws(() => identityFromCreds(spliced), /!= JWT subject/); +} + +// (d) A creds string with no seed block throws the documented error rather than returning a bogus id. +assert.throws(() => identityFromCreds("this is not a creds file"), /no user nkey seed block found/); + +console.log("identity.smoke: all assertions passed"); From 994e85ce92a1b3a0ab26a53c2f85021b5b1fee5f Mon Sep 17 00:00:00 2001 From: Matt Wilkinson Date: Wed, 8 Jul 2026 19:55:50 -0400 Subject: [PATCH 4/4] test(cli): assert corrupt-creds mint leaves the file untouched MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cubic P2 on #7: the corrupt-creds check only asserted the error message, so a future mint() refactor that wrote fresh creds *before* surfacing the parse error would still pass while silently rotating the id — the exact regression the test guards. Add a filesystem-state assertion: after the failed mint, the corrupt file must be byte-unchanged (still empty), proving no silent fresh-mint. Refs #3. Co-Authored-By: seal --- implementations/cli/smoke/mint-reuse.smoke.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/implementations/cli/smoke/mint-reuse.smoke.ts b/implementations/cli/smoke/mint-reuse.smoke.ts index d6beb985..b7052fb0 100644 --- a/implementations/cli/smoke/mint-reuse.smoke.ts +++ b/implementations/cli/smoke/mint-reuse.smoke.ts @@ -116,14 +116,27 @@ try { // 6) A present-but-unparseable creds file at the out path fails LOUD naming --force — never a raw // parse crash, and never a silent fresh-mint (which would orphan the predecessor id's durables). mkdirSync(credsDir, { recursive: true }); - writeFileSync(join(credsDir, "corrupt.creds"), ""); // present but no seed block + const corruptCreds = join(credsDir, "corrupt.creds"); + writeFileSync(corruptCreds, ""); // present but no seed block let msg = ""; try { await mintQuiet(["corrupt"]); } catch (e) { msg = e instanceof Error ? e.message : String(e); } - check("unparseable existing creds → actionable error naming --force (no silent rotate, no raw crash)", /could not be parsed/.test(msg) && /--force/.test(msg), { msg }); + check( + "unparseable existing creds → actionable error naming --force (no silent rotate, no raw crash)", + /could not be parsed/.test(msg) && /--force/.test(msg), + { msg }, + ); + // Assert the FILESYSTEM state, not just the message: the failed mint must not have written fresh + // creds over the corrupt file. A future refactor that mints-then-throws would still surface a + // parse error yet silently rotate the id — this catches that by proving the file is untouched. + check( + "failed corrupt-creds mint left the file untouched (no silent fresh-mint)", + readFileSync(corruptCreds, "utf8") === "", + { content: readFileSync(corruptCreds, "utf8").slice(0, 40) }, + ); } finally { process.chdir(prevCwd); rmSync(root, { recursive: true, force: true });