security: make DEBUG a build-time flag defaulting to off (closes #149) #169
Reference in New Issue
Block a user
Delete Branch "fix/issue-149-debug-build-flag"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Fixes the highest-severity item in the repo:
src/shared/constants.jshadconst DEBUG = true;, sogenerateMnemonic()returned the publicly committedDEBUG_MNEMONICfor every wallet created from a build ofmain, and the realentropy path was dead code in every artifact we could produce.
What changed
build.js—AUTISTMASK_DEBUGis read from the environment and injectedas a
__BUILD_DEBUG__entry in the existing esbuilddefinemap, next to theother
__BUILD_*__defines. Only the exact value1enables it; unset, empty,true, or a typo all yield a release build, so the insecure direction requiresa deliberate opt-in and any mistake fails safe. The build prints
Build mode: release (DEBUG off)orBuild mode: DEBUG (INSECURE - hardcoded test mnemonic, do not ship).src/shared/constants.js—DEBUGnow uses the sametypeofguard thatsrc/shared/buildInfo.jsalready uses for the other build-time defines, anddefaults to
falsewhen the define is absent (jest, plainrequire).DEBUG_MNEMONICstays in the tree and stays exported.Makefile— newbuild-debugtarget (AUTISTMASK_DEBUG=1+ the samebuild) so a debug build stays a one-liner for development.
README.md— new "Debug Builds" subsection under Getting Started, and theDEBUG Mode Policy section now states that
DEBUGis build-time-only and spellsout the boundary against the runtime toggle.
tests/wallet.test.js— new, covering both build modes.TODO.md— refreshed in the same commit (details at the bottom).No new
if (DEBUG)branch was added and nothing about what DEBUG does changed:still exactly the red banner plus the hardcoded test phrase, per the README
DEBUG Mode Policy and
RULES.md:76-80.The interaction with the #145 settings toggle
This is the subtle part, so spelling out the reasoning.
There are two distinct debug flags in the tree after #145:
DEBUGconstant fromconstants.js, anddebugModestate flag, which the settings easter egg togglesand which
settings.js:379pushes intolog.jsviasetRuntimeDebug().log.jsmerges them:isDebug()isDEBUG || _runtimeDebug. That mergedvalue feeds exactly two things — the log level threshold (
log.js:24) and thered banner (
views/helpers.js:71). Making the banner user-toggleable is theintended behavior of #145, and this PR leaves it alone.
generateMnemonic()does not consultisDebug(). It reads thecompile-time
DEBUGbinding directly. That distinction is what makes a releasebuild coherent: with
__BUILD_DEBUG__false,DEBUGis false in the bundle,so no amount of clicking the version ten times and flipping the toggle can
reach
return DEBUG_MNEMONIC. The user can turn the banner and verbose loggingon in a release build; they cannot turn the hardcoded phrase on.
The failure mode to guard against is someone later "tidying up" the two flags by
routing
wallet.jsthroughisDebug(), which would silently reintroduce thisexact vulnerability with the runtime toggle as the trigger. Three things now
guard that: a comment at the
wallet.jscall site saying it must stay thecompile-time constant and why, the same statement in the README DEBUG Mode
Policy, and a regression test that calls
setRuntimeDebug(true), assertsisDebug()is genuinely true, and then assertsgenerateMnemonic()stillreturns fresh entropy.
I considered instead making the runtime toggle unavailable in release builds,
but rejected it: that removes a feature #145 deliberately added, and it defends
the wrong boundary. The banner is not the dangerous part; the mnemonic path is,
and that one is already unreachable.
Verification
make check— green, 5 suites, 55 tests, plus lint and fmt-check. It also ranvia the pre-commit hook on the commit itself.
The new tests, per the verification standard in the manager comment on the
issue (not just
a !== b) — with the flag off: two successivegenerateMnemonic()calls differ, both passisValidMnemonic, both are 12words, neither equals
DEBUG_MNEMONIC, and the result derives a usable HDwallet (
xpub+ a well-formed first address), so a broken implementationreturning a counter or a truncated phrase would fail. Same assertions again
with the runtime toggle forced on. With the flag on (
__BUILD_DEBUG__definedbefore a
jest.resetModules()re-require):DEBUGistrueandgenerateMnemonic()returnsDEBUG_MNEMONIC, so the debug path is provenworking rather than silently deleted.
Build artifacts —
make buildandmake build-debugboth producedist/chromeand
dist/firefoxsuccessfully. Grepping the minified bundles for the emittedDEBUGexport value across all four bundles (chrome popup, chrome background,firefox popup, firefox background):
!1is minifiedfalse,!0istrue. Also checked the fail-safe path:AUTISTMASK_DEBUG=true make buildprintsBuild mode: release (DEBUG off)andlikewise yields
4 DEBUG:!1.One thing a reviewer should know about the grep: the
DEBUG_MNEMONICstringliteral is still present in the release bundle. That is not a leak of anything
(the phrase is in this public repo already) and it does not mean the branch is
live — esbuild cannot tree-shake a CommonJS
module.exportsobject, so theconstant survives while
DEBUGfolds tofalse. The compiled function isfunction PL(){return ML?UL:f_.fromEntropy(globalThis.crypto.getRandomValues(new Uint8Array(16))).phrase}where
MLis theDEBUG:!1export. So "the phrase string is absent" is notthe right test for a release build; "the exported
DEBUGis!1" is, which iswhat I checked.
TODO.mdrefreshPer the manager comment: Status rewritten (no branch in flight —
feat/issue-144-settings-aboutlanded as #145, scripts-to-rule-them-all landedas #148, so the
scripts/question is resolved;make checkrecorded asverified green on
mainat23aeae4); the completed "Verify main passes makecheck" Future Step removed; Future Steps rewritten against the #149-#168
backlog in rough priority order, keeping branch pruning (now #167) and the
pre-1.0 security review (noting #149 and #157 are parts of it but it is
broader).
One deliberate deviation to flag rather than bury: the manager asked that Next
Step become this issue. Taken literally against the Workflow section, this
commit completes #149, which would normally move it into Completed Steps. I
followed the repo's existing convention for in-flight work instead — the
previous Next Step was phrased as "Land feat/issue-144-settings-about", so Next
Step is now "Land #149 ... PR open, awaiting review", which is accurate until
this merges. Whoever merges should move it to Completed Steps and promote the
first Future Step. Happy to change it if the reviewer prefers the strict
reading.
Out of scope
script/lintbeingprettier --checkonly and unable to catch undefinedidentifiers (#152) — noted in the TODO but not fixed here; I greped for
DEBUGconsumers by hand rather than relying on lint, as advised. Nothing else in the
DEBUG consumer set (
log.js,helpers.js,state.js,settings.js) changedbehavior.
Ready for review. Summary of what landed on
fix/issue-149-debug-build-flag(single commit
acb5885, 7 files, +207/-21):Built
build.js:AUTISTMASK_DEBUGenv var, injected as a__BUILD_DEBUG__esbuild define beside the existing
__BUILD_*__defines. Exact1opts in;every other value produces a release build. Build mode is printed.
src/shared/constants.js:DEBUGreads the define via the sametypeofguard as
src/shared/buildInfo.js, defaulting tofalse.DEBUG_MNEMONICunchanged and still exported.
src/shared/wallet.js: comment only — records thatgenerateMnemonic()mustkeep reading the compile-time constant and must never be switched to
isDebug().Makefile:build-debugtarget.README.md: "Debug Builds" section, and DEBUG Mode Policy now documents thebuild-time-vs-runtime split.
tests/wallet.test.js: new, 6 tests over both build modes.TODO.md: refreshed per the manager comment (same commit as the code).Verified
make check: 5 suites, 55 tests, lint and fmt-check all green. Also ranthrough the pre-commit hook on the commit.
make build:dist/chromeanddist/firefoxboth produced;grep -roh 'DEBUG:![01]' dist/chrome dist/firefox | sort | uniq -cgives4 DEBUG:!1(four bundles: popup and background for each browser).make build-debug: same four bundles,4 DEBUG:!0.AUTISTMASK_DEBUG=true make build: printsBuild mode: release (DEBUG off)and yields
4 DEBUG:!1— the fail-safe path.The bit worth reviewing carefully is the runtime toggle from #145. The
settings easter egg drives
_runtimeDebuginlog.js, andisDebug()isDEBUG || _runtimeDebug, which feeds the log threshold and the banner only.generateMnemonic()reads the compile-timeDEBUGbinding, notisDebug(), soa release build cannot have the hardcoded phrase re-enabled by any runtime
action. Full reasoning, and why I did not instead disable the toggle in release
builds, is in the PR body. A regression test asserts
isDebug()is true andgenerateMnemonic()still returns fresh entropy at the same time.Two things called out in the PR body that a reviewer should not have to
rediscover: (1) the
DEBUG_MNEMONICstring literal legitimately survives in therelease bundle because esbuild cannot tree-shake a CommonJS exports object, so
grepping for the phrase is the wrong test — grep the exported
DEBUGvalue;(2) I set
TODO.mdNext Step to "land #149, PR open, awaiting review" ratherthan moving it straight to Completed Steps, matching how the previous in-flight
Next Step was written; say the word if the strict Workflow reading is preferred.
Review: PR #169 — PASS
Independent adversarial review. Nothing below is taken from the author's
claims; every assertion was re-derived locally against head
acb5885in athrowaway worktree. No files were modified.
Verdict
PASS. Label
merge-ready, assign tosneak. The vulnerability isgenuinely fixed, the runtime-toggle boundary holds, and every DoD item in #149
plus both extra requirements from the manager comment on the issue are met.
Three non-blocking findings are recorded at the bottom; none of them justify
holding the highest-severity fix in the repo.
What I verified myself
1. Vulnerability actually fixed. Confirmed, and confirmed at the level of
the emitted artifact, not the source.
make build, thengrep -ro 'DEBUG:![01]' dist/chrome dist/firefox:DEBUG:!1in all four bundles that containconstants.js(
{chrome,firefox}/src/popup/index.js,{chrome,firefox}/src/background/index.js).The other four emitted JS files (
content/index.js,content/inpage.js)do not bundle
constants.jsat all — confirmed by grepping each fileindividually for both
DEBUG:!and the phrase literal. So "4 of 4", not"4 of 8 unexplained".
make build-debug:DEBUG:!0in the same four. The debug path is intact,not deleted.
__BUILD_DEBUG__remain in any emitted file, so theesbuild define is fully applied and nothing falls through to the
typeof-guard default in shipped code.2. The
DEBUG_MNEMONICliteral surviving the release bundle — assessed, andthe author's characterisation is correct. I did not take it on trust:
oB.exports={DEBUG:!1,DEBUG_MNEMONIC:ok,...}— a literal
false, not a computed value.wallet.jscompiles tovar{Mnemonic:f_,...}=kn(),{DEBUG:ML,DEBUG_MNEMONIC:UL,BIP44_ETH_PATH:l_}=qn();function PL(){return ML?UL:f_.fromEntropy(globalThis.crypto.getRandomValues(new Uint8Array(16))).phrase}MLis bound once by that destructure and never assigned anywhere in anybundle — I grepped every bundle for
[^A-Za-z_$]ML *=[^=]and for[A-Za-z_$.]{1,20}\.DEBUG *=; both return nothing. Same result for thebackground bundle's corresponding identifier
ZD. Because the value iscaptured by destructure at module-init time, even mutating the exports
object afterwards could not change it.
So the ternary is genuinely dead at runtime and the surviving string is inert.
Grepping for the phrase is indeed the wrong test; grepping the exported
DEBUGvalue is the right one. Agreed.3. The #145 runtime-toggle interaction — the crux. Claim verified, hard. I
traced every consumer rather than reading the argument:
grep -rn 'DEBUG\|isDebug\|setRuntimeDebug\|debugMode' src/yields thecomplete consumer set.
isDebug()(src/shared/log.js:19-21,DEBUG || _runtimeDebug)has exactly two call sites:
src/shared/log.js:24(log threshold) andsrc/popup/views/helpers.js:71(banner)._runtimeDebugis written only bysetRuntimeDebug(), called fromsrc/popup/index.js:209andsrc/popup/views/settings.js:379,385.DEBUG_MNEMONICappears in exactly two files:src/shared/constants.js:11andsrc/shared/wallet.js:12.generateMnemonic()is called from exactly one place,src/popup/views/addWallet.js:286(the die button).src/shared/wallet.js:5imports
DEBUGfrom./constantsdirectly and never importslog.js.There is no path from the easter egg to the hardcoded phrase. In a release
build a user can turn the banner and verbose logging on and cannot reach
return DEBUG_MNEMONIC. Confirmed in the compiled artifact too, sinceMListhe literal-
falseexport and is never reassigned.The author's rejected alternative (disabling the toggle in release builds) was
the right thing to reject: it would have removed a feature #145 deliberately
added while defending a boundary that is not the dangerous one.
Also confirmed no regression in #145 itself —
settings.js,state.js,helpers.jsandlog.jsare untouched by this diff, andstate.debugModestill defaults to
false(src/shared/state.js:32).4. DoD item "the red banner does not appear" in a release build. Holds by
construction:
helpers.js:73isdebug || net.isTestnet,debugisfalse || falsein a release build with the default state, and the defaultnetwork is mainnet (
src/shared/networks.js:15,isTestnet: false).5. Env var fail-safe. Verified empirically, not from the source. Each of
these prints
Build mode: release (DEBUG off):AUTISTMASK_DEBUG=true,AUTISTMASK_DEBUG=(empty),AUTISTMASK_DEBUG=01,AUTISTMASK_DEBUG=" 1"(leading space),AUTISTMASK_DEBUG=yes. I confirmedthe
truecase also produces4 DEBUG:!1in the artifact, not just the logline. Only the exact
1opts in.make build-debugworks, and the README'sdocumented alternative
AUTISTMASK_DEBUG=1 make buildalso works (envpropagates through make to yarn) — verified, both produce
DEBUG:!0.Dockerfile:17runs a baremake buildwith noAUTISTMASK_DEBUGARG or ENV,so the container build is a release build.
6. Test quality — meets the manager's standard, non-vacuous.
tests/wallet.test.jsasserts validity viaisValidMnemonic, 12-word count,inequality of successive calls, inequality against
DEBUG_MNEMONIC, HDderivability of the result, and the debug path still returning
DEBUG_MNEMONIC. The regression test attests/wallet.test.js:56-66is theimportant one and it is not vacuous: it asserts
log.isDebug()is genuinelytruebefore asserting the phrase is still fresh, so it would not passsilently if the toggle stopped working.
Against the old broken code (
const DEBUG = true) three of the six tests fail:DEBUG defaults to false,returns fresh, valid 12-word phrases(at
expect(first).not.toBe(second)), and the runtime-toggle regression test.The suite is a real gate on this bug.
I tried to construct broken implementations that still pass. A fixed-but-valid
phrase fails on
not.toBe(second); a counter or truncated phrase failsisValidMnemonicand the word count; deleting the debug branch fails thedebug-build describe; routing through
isDebug()fails the regression test.The only survivors are low-entropy sources (
Math.randominstead ofgetRandomValues), which no reasonable unit test catches, and the gap noted infinding 2 below.
7.
make check— green, run by me. 5 suites, 55 tests, plusprettier --checklint and fmt-check, all pass. Run viamake checkonly; nodirect yarn/jest/prettier invocation. CI on head
acb5885issuccess("check / check (push)", 29s). Mergeable against currentmain:API reports
mergeable: trueandgit merge-base --is-ancestor origin/main HEADconfirms the head already contains current
main— no rebase needed.8. Policy. No occurrence of the disallowed vendor names anywhere in the
diff, the commit message, or the PR body; the only repo-wide hits are
pre-existing entries in
src/shared/phishingBlocklist.jsonand a.prettierignoreline, neither touched here. No attribution trailers inacb5885. Commit subject ends in(closes #149).RULES.mdis not in thediff. No stray files:
git diff --name-statusshows exactly the 7 intendedpaths and
git statusis clean, so nogit add -Asweep.TODO.mdis in thesame commit.
make fmt-checkpasses on the markdown. No newif (DEBUG)branch —
wallet.js:12is the pre-existing one and the only addition there isa comment, so
RULES.md:76-80and the README DEBUG Mode Policy are respected.constants.js:1-8matchesbuildInfo.js:4-22exactly in idiom, including the/* global */directive. No stutter inisDebugBuild()/__BUILD_DEBUG__/build-debug. No non-inclusive terminology introduced. No scope creep: theREADME.mdandTODO.mdedits are both explicitly mandated, by the issue andby the manager comment respectively.
build-debugshelling straight toyarn run buildrather than ascript/entrypoint is consistent with the pre-existing
buildtarget and is not a newdivergence; the scripts-to-rule-them-all canonical set in
REPO_POLICIES.mdisunaffected. Not a finding.
Non-blocking findings
F1.
build.js:18-20— a set-but-unrecognisedAUTISTMASK_DEBUGis silentlyignored.
return process.env.AUTISTMASK_DEBUG === "1";meansAUTISTMASK_DEBUG=trueproduces a release build with no diagnostic about the value having been
discarded. Silent defaulting on a set-but-unparseable config value is normally
a rejected pattern. I am not blocking on it, for two specific reasons: the
silent direction is the safe one, and the build prints its resulting mode
unconditionally on every invocation, so no operator can end up unaware of which
artifact they built. Acceptable hardening if the owner wants it: treat unset,
empty,
0and1as valid andprocess.exit(1)withAUTISTMASK_DEBUG must be 0 or 1, got "<value>"on anything else — that isboth loud and still fail-safe, since an aborted build ships nothing.
F2. Nothing automatically guards the esbuild wiring itself.
The release-mode tests exercise the
typeof __BUILD_DEBUG__ !== "undefined"fallback in
src/shared/constants.js:8, which is the jest path, not thebundler path. If a future edit dropped
__BUILD_DEBUG__from thedefinemapat
build.js:66, all six tests intests/wallet.test.jswould still pass andthe bundle would still be safe by accident (the fallback is
false) — but theinverse mistake, a define that resolves truthy, is equally untested. For the
single highest-severity failure mode in the repo, an artifact-level assertion
would be worth having: a
script/step aftermake buildthat fails unlessevery emitted bundle containing
DEBUG:showsDEBUG:!1, or a unit test onbuild.js's define map. Not required by the DoD and not a defect in thischange; recording it as a follow-up worth filing.
F3.
tests/wallet.test.js:65—log.setRuntimeDebug(false)runs in the testbody rather than an
afterEach.If an earlier assertion in that test throws, the cleanup is skipped. Harmless
today because the
beforeEachjest.resetModules()hands the next test afresh
logmodule with_runtimeDebugback atfalse, so there is no realleak — but the cleanup line is then also load-bearing for nothing and is
misleading. Cosmetic.
Verdict on the flagged TODO.md deviation
The author's phrasing stands; no change required.
The manager comment said Next Step should become this issue, and
TODO.md:20-26does exactly that. The tension the author identified with theWorkflow section (
TODO.md:4-6, "do the work in Next Step / move Next Step tothe top of Completed Steps") is real, but the literal instruction from the
manager is the more specific and more recent one, and the in-flight phrasing
matches the convention the file already used for
feat/issue-144-settings-about.Flagging it rather than burying it was the correct call.
One consequence to be aware of at merge time, since the repo's default merge
style is squash and the merge will not touch
TODO.md: the moment this lands,mainwill carry a Next Step reading "PR open, awaiting review" for a mergedPR. Whoever merges should move it to Completed Steps and promote #150/#151 into
Next Step, as the author anticipated. That is a merge-time action, not a
rework item.
Manager note (the review verdict is in its own comment above).
Independent adversarial review passed with no blocking findings. The reviewer
did not author this change, and re-derived the critical claims from the built
artifacts rather than accepting the author's word: all four bundles that
actually contain
constants.jscompile toDEBUG:!1, the survivingDEBUG_MNEMONICliteral is provably dead (MLis bound once at destructureand never reassigned), and there is no path from the #145 easter-egg toggle to
the hardcoded phrase —
isDebug()has exactly two call sites, neither inwallet.js. The reviewer also tried to construct broken-but-passingimplementations against the new tests and only low-entropy sources survived.
Marking
merge-readyand assigning to @sneak for merge, sincemainisprotected.
One thing to do at merge time. This PR is a squash merge, so
TODO.mdwillland carrying "Land #149 … PR open, awaiting review" for a PR that is by then
merged. Whoever merges should move that line to Completed Steps and promote the
next item. I will pick this up in the following work unit if it is easier to
let it ride for one commit — flagging it so it is a choice rather than a
surprise.
Three non-blocking findings were recorded by the reviewer. My dispositions:
AUTISTMASK_DEBUG=trueis silently discarded (build.js:18-20).Not acting. The silent direction is the safe one and the build prints its
resulting mode unconditionally, so it is not actually silent to the operator.
__BUILD_DEBUG__from
build.jswould leave all six tests green, because the tests exercisethe jest fallback path, not the bundler path. This is the only durable
defence against this exact vulnerability returning, so I have filed it as
#170 against the 1.0.0 milestone.
setRuntimeDebug(false)in the test body rather thanafterEach(
tests/wallet.test.js:65). Cosmetic, harmless given thebeforeEachjest.resetModules(). Not acting.