Skip to content

Commit ca31684

Browse files
ralyodioclaude
andcommitted
fix(trust): a refusal that says what to do about it
The name-constraints gate refused correctly and then left you there. Both refusals printed the same line -- "moshcode will not put an unconstrained root in your trust store" -- with no next step, and for one of them that sentence was not even true. They are nothing alike. A root permitting endings other than the ones claimed today is *stale*, not dangerous: it was generated when a different set was claimed, and regenerating it costs one command. A root with no permitted DNS subtree can vouch for any name on the internet. Collapsing those into one dead end is how a safety gate ends up being worked around with --no-trust rather than satisfied -- the operator has no way to tell "this is a real hole" from "this is out of date", so the opt-out becomes the obvious move. So each refusal now carries its own remedy, and only one of them has a way forward: STOP the root does not permit rank the root is older than the endings claimed now — regenerating it is enough: rm -rf /home/anthony/.moshpit/ca && moshpit-proxy # writes a fresh root then re-run `moshcode dns enable`. STOP the root permits no DNS subtree, so every name it does not exclude is allowed moshcode will not put a root that can vouch for names outside Moshpit into your trust store. ... This is not overridable. The dangerous case is deliberately offered nothing, and a test asserts it never advertises --no-trust as the fix. This is the strictness note from #275 answered: the rule stays, because it is the actual guarantee, but it is no longer a dead end when a root is simply out of step. 1351 tests, 1348 pass, 0 fail. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 6d36fc2 commit ca31684

3 files changed

Lines changed: 82 additions & 7 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "moshcode",
3-
"version": "0.19.0",
3+
"version": "0.19.1",
44
"type": "module",
55
"description": "moshcode — a metal wrapper for coding engines and native UGig/CoinPay workflow CLIs, with OpenPRD and moshscript",
66
"bin": {

src/trust.mjs

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -118,19 +118,20 @@ export function requireNameConstraints(text, { tlds = [] } = {}) {
118118
// constraints in an unconstrained one — passing or failing everything.
119119
const body = String(text || "");
120120
if (!/Certificate:|Signature Algorithm:/i.test(body)) {
121-
return { ok: false, why: "could not read the certificate — openssl printed nothing usable" };
121+
return { ok: false, kind: "unreadable", why: "could not read the certificate — openssl printed nothing usable" };
122122
}
123123
const constraints = parseNameConstraints(body);
124124
if (!constraints) {
125125
return {
126126
ok: false,
127+
kind: "unconstrained",
127128
why: "the root carries no name constraints — it could vouch for any name, not just Moshpit",
128129
};
129130
}
130131
// Non-critical constraints are advisory: a verifier is free to ignore an
131132
// extension it does not recognise, which turns the guarantee into a comment.
132133
if (!constraints.critical) {
133-
return { ok: false, why: "the name constraints are not marked critical, so a verifier may ignore them" };
134+
return { ok: false, kind: "unconstrained", why: "the name constraints are not marked critical, so a verifier may ignore them" };
134135
}
135136
// RFC 5280 §4.2.1.10: constraints bind only the name *types* they mention. A
136137
// root whose DNS entries are all exclusions — or whose permitted subtree
@@ -141,6 +142,7 @@ export function requireNameConstraints(text, { tlds = [] } = {}) {
141142
if (!constraints.permitted.length) {
142143
return {
143144
ok: false,
145+
kind: "unconstrained",
144146
why: "the root permits no DNS subtree, so every name it does not exclude is allowed — it could vouch for any name",
145147
};
146148
}
@@ -150,7 +152,7 @@ export function requireNameConstraints(text, { tlds = [] } = {}) {
150152

151153
const missing = tlds.filter((tld) => !permits(tld));
152154
if (missing.length) {
153-
return { ok: false, why: `the root does not permit ${missing.join(", ")}` };
155+
return { ok: false, kind: "out-of-step", why: `the root does not permit ${missing.join(", ")}` };
154156
}
155157
// And nothing beyond them. One `DNS:.com` in the permitted subtree is the
156158
// whole hole this gate exists to close, and it would otherwise sail through
@@ -160,6 +162,7 @@ export function requireNameConstraints(text, { tlds = [] } = {}) {
160162
if (foreign.length) {
161163
return {
162164
ok: false,
165+
kind: "out-of-step",
163166
why: `the root also permits ${foreign.join(", ")}, which is not an ending we resolve — it reaches past Moshpit`,
164167
};
165168
}
@@ -255,7 +258,7 @@ export function trustPlan({
255258
const constrained = requireNameConstraints(caText, { tlds });
256259
if (!constrained.ok) {
257260
// Refused rather than warned. A warning here would be read past.
258-
return { ok: false, refused: true, steps: [], why: constrained.why };
261+
return { ok: false, refused: true, kind: constrained.kind, steps: [], why: constrained.why };
259262
}
260263

261264
const steps = [];
@@ -275,6 +278,36 @@ export function trustPlan({
275278
return { ok: true, steps, skipped, file, why: constrained.why };
276279
}
277280

281+
/**
282+
* What to do about a refusal, which depends entirely on which one it is.
283+
*
284+
* `out-of-step` is the common one and is not a security event: the root was
285+
* generated when a different set of endings was claimed, so it is stale rather
286+
* than dangerous. Regenerating costs one command, and without saying so the
287+
* strict check reads as a dead end — which is how a safety gate ends up being
288+
* disabled with --no-trust instead of satisfied.
289+
*
290+
* `unconstrained` is the dangerous one, and deliberately has no workaround
291+
* offered: the answer is a fixed root, never a way around the check.
292+
*/
293+
export function refusalRemedy(kind, file) {
294+
if (kind === "out-of-step") {
295+
return [
296+
"the root is older than the endings claimed now — regenerating it is enough:",
297+
` rm -rf ${path.dirname(file)} && moshpit-proxy # writes a fresh root`,
298+
"then re-run `moshcode dns enable`.",
299+
];
300+
}
301+
if (kind === "unreadable") {
302+
return [`could not read ${file} — check it is a certificate and openssl is installed.`];
303+
}
304+
return [
305+
"moshcode will not put a root that can vouch for names outside Moshpit",
306+
"into your trust store. Names will resolve but not pass TLS until the",
307+
"root is regenerated with name constraints. This is not overridable.",
308+
];
309+
}
310+
278311
/** Run a command, never throwing — the caller reports, it does not crash. */
279312
async function run(command, args) {
280313
const { execFile } = await import("node:child_process");
@@ -332,7 +365,12 @@ export async function applyTrust(tlds, out, deps = {}) {
332365
if (!plan.ok) {
333366
// A refusal is the feature working, so it says which it is.
334367
out(plan.refused ? ` STOP ${plan.why}` : ` -- ${plan.why}`);
335-
if (plan.refused) out(" moshcode will not put an unconstrained root in your trust store.");
368+
// ...and then how to get past it. A gate that only says "no" is a gate
369+
// people work around, and the two refusals are nothing alike: one is a
370+
// root that must never be installed, the other a root that is simply
371+
// older than the endings claimed today. Telling them apart is the
372+
// difference between "this is dangerous" and "regenerate it".
373+
for (const line of refusalRemedy(plan.kind, file)) out(` ${line}`);
336374
return { ok: false, why: plan.why };
337375
}
338376

test/trust.test.mjs

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import { execFileSync } from "node:child_process";
2222

2323
import {
2424
applyTrust, caPath, describeCertificateCommand, operatorHome, parseNameConstraints,
25-
requireNameConstraints, trustPlan, trustStores, verifyStockTls,
25+
refusalRemedy, requireNameConstraints, trustPlan, trustStores, verifyStockTls,
2626
} from "../src/trust.mjs";
2727

2828
/**
@@ -394,3 +394,40 @@ test("no root yet points at what makes one, and is not a failure of enable", asy
394394
assert.equal(result.ok, false);
395395
assert.match(lines.join("\n"), /moshpit-proxy generates one/);
396396
});
397+
398+
/* ------------------------------------------------- refusals say what to do */
399+
400+
test("a stale root is told how to be regenerated, not just refused", (t) => {
401+
// Permits a real Moshpit ending, just not the one claimed today. Nothing
402+
// dangerous — the root predates the current ending list. A refusal that only
403+
// says "no" is how a safety gate ends up disabled with --no-trust instead of
404+
// satisfied, so this one has to carry the one command that fixes it.
405+
const text = needRoot(t, "critical,permitted;DNS:.hacker");
406+
if (!text) return;
407+
const verdict = requireNameConstraints(text, { tlds: ["rank"] });
408+
assert.equal(verdict.ok, false);
409+
assert.equal(verdict.kind, "out-of-step");
410+
411+
const remedy = refusalRemedy(verdict.kind, "/home/x/.moshpit/ca/ca.crt").join("\n");
412+
assert.match(remedy, /rm -rf \/home\/x\/\.moshpit\/ca/, "names the directory to clear");
413+
assert.match(remedy, /moshpit-proxy/, "and what regenerates it");
414+
});
415+
416+
test("a dangerous root is offered no way around the check", (t) => {
417+
const text = needRoot(t, "critical,excluded;DNS:.hacker");
418+
if (!text) return;
419+
const verdict = requireNameConstraints(text, { tlds: ["hacker"] });
420+
assert.equal(verdict.kind, "unconstrained");
421+
422+
const remedy = refusalRemedy(verdict.kind, "/home/x/.moshpit/ca/ca.crt").join("\n");
423+
assert.match(remedy, /not overridable/);
424+
assert.doesNotMatch(remedy, /--no-trust/, "never advertises the opt-out as the fix");
425+
});
426+
427+
test("the refusal a session prints carries its remedy", async () => {
428+
const h = harness({ caText: "Certificate:\n no constraints here" });
429+
await applyTrust(["hacker"], h.out, h.deps);
430+
const text = h.lines.join("\n");
431+
assert.match(text, /STOP/);
432+
assert.match(text, /not overridable/, "the STOP line alone is not actionable");
433+
});

0 commit comments

Comments
 (0)