feat(sha256): platform-neutral SHA-256, unblocking the XS daemon bundle - #903
feat(sha256): platform-neutral SHA-256, unblocking the XS daemon bundle#903kriscendobot wants to merge 12 commits into
Conversation
A module that statically imports `node:crypto` cannot pass through the
SES/XS bundler, which has no `node:crypto` to resolve. `@endo/sha256`
gives such a module one import specifier that resolves to a working
digest on every target, through conditional exports:
- `node` -> `node:crypto`'s `createHash('sha256')`, copied off the
`Buffer` so the result is a plain `Uint8Array`.
- `xs` -> the Endor Rust host's binary-safe incremental triple
(`hostSha256Init` / `hostSha256UpdateBytes` / `hostSha256Finish`), so
the first increment needs no new Rust. It prefers a one-shot
`hostSha256Bytes` if the host ever grows one, and falls back to the
pure-JS digest under an XS engine that is not the Endor host. It
never uses the host's one-shot `hostSha256`, which takes a UTF-8
string and so corrupts any input byte above 0x7f.
- `browser` and `default` -> a pure-JS synchronous SHA-256, lifted from
the browser stand-in in `packages/chat/node-crypto-shim.js`.
`default` maps to the pure-JS build so the package is safe under any
bundler that sets none of the three conditions.
The API is synchronous because the sole in-graph consumer,
`makeBlobRefExo`, content-addresses inside a synchronous exo factory;
WebCrypto's async `crypto.subtle.digest` cannot back it. Streaming stays
out of scope: it is already served by the injected
`CryptoPowers.makeSha256`.
Tests cross-check all three builds against `node:crypto` over FIPS 180-4
vectors and lengths straddling the block and padding boundaries, and
exercise the xs build against a stand-in host implementing the Rust
contract.
Design: designs/platform-neutral-hash.md
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`blob-ref.js` is on the XS daemon bundle's compartment graph, and its static `node:crypto` import was the named blocker for generating `daemon_bootstrap.js`. Swapping it for `@endo/sha256` is behavior-preserving: `encodeBase64` already operated over the digest as a `Uint8Array` rather than through `Buffer.prototype.toString`, so the recorded hash is byte-for-byte what it was. Design: designs/platform-neutral-hash.md Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The shim carried its own 100-line pure-JS SHA-256, which is now `@endo/sha256`'s browser build. The shim keeps only the `createHash` streaming shape that `node:crypto` callers expect and takes the digest from the package, so the two cannot drift apart. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`bundle-bus-daemon-rust-xs.mjs` failed with sixteen unresolvable `node:` imports. `@endo/sha256` cleared one of them; this clears the other fifteen, so `daemon_bootstrap.js` is produced once more and `test:rust` and the endor daemon integration are unblocked. Two legs, both "a module on the graph statically imports something XS cannot resolve": - `@endo/exo-git` reached `readOnly` and `wrapBackend` through the `@endo/platform/fs/extended` index, which also re-exports `makeNodeFilesystem` and `makeNodeFsBackend` and so pulled in `node:fs`, `node:fs/promises`, and `node:path`. It now imports the two modules it uses directly. - `manager.js` and `host.js` statically imported `@endo/git` and `@endo/host-spawner`, which between them reach nine `node:` builtins and could not run under XS regardless: both shell out to a host process. They are now injected as `DaemonicPowers.hostTools`, the way `better-sqlite3` already reaches the daemon as an injected `Database`. Every Node supervisor supplies them from `host-tool-powers-node.js`; the XS supervisor supplies none and gets stand-ins that refuse with a diagnosis, so a `git` or `shell` formula under XS reports what is unavailable instead of failing to link. The bundler now also passes the `xs` condition, which is how the repository's own bundler comments say to steer retention. Without it `@endo/sha256` would silently resolve its `default` arm, a pure-JS digest, rather than binding to the host's Rust-native SHA-256. Design: designs/platform-neutral-hash.md Refs: rust/endo/README.md, "The XS daemon bundle pulls in Node-only packages" Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Marks the design In Progress, resolves its three open questions as the implementation resolved them, and records the two blockers the original survey did not anticipate: the `@endo/platform/fs/extended` index leg reached through `@endo/exo-git`, and the `@endo/git` / `@endo/host-spawner` exclusion, which turned out to be a prerequisite for generating the bundle rather than a parallel workstream. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Correctness, from the corner-prober seat: - The preferred one-shot XS host path returned whatever the host handed back. `new Uint8Array` coerces rather than refuses, so a host returning the number 32 produced thirty-two zero bytes: one content address shared by every blob, silently. Every digest arriving from a host function is now type- and length-checked, and `sha256Into` checks before writing so a short digest cannot leave stale destination bytes behind a return value of 32. - The streaming triple leaked a host handle when `hostSha256Update Bytes` threw, since `hostSha256Finish` is what consumes it. - The pure-JS padded length used `>> 6`, which coerces to signed 32-bit and goes negative at 2**31 - 8 bytes, throwing where the node build hashes happily. Computed by division now: the builds must not diverge on any input. Tests: fast-check properties over generated input for the cross-build-agreement claim the header states universally and the suite checked at eight lengths, plus host stand-ins returning a short buffer, a long buffer, a number, and a string, plus a throwing update that must not leak a handle. The generated cases replace unseeded `randomBytes`, so a CI failure now reports a seed and a shrunk input. Changesets for the three published surfaces (the new package, the `@endo/platform` shim requirement dropped, the `@endo/daemon` powers field), which the changeset-auditor seat blocked on. Documentation: - `rust/endo/README.md` still described this bundle as failing and prescribed the fix as future work, with specifics the build falsified, and `host-tool-powers.js` cited it as the durable record. - The design's "Open questions" held three answered questions with their deliberation intact; they are resolved questions now, and the deferred host function's trigger names an owner. - Dropped the `sha256Into` allocation claim, which was backwards: it saves a 32-byte array and still allocates its padded input. - Trimmed the WebCrypto rationale from five statements to one, and the README API block from pasted JSDoc to prose signatures. - Said why the sibling XS bundle generators do not pass the `xs` condition. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three seats independently ran the same class of attack and it worked: a value that answers a different length to each read is not caught by `instanceof`, and the pure-JS build read `bytes.length` four separate times. A `Proxy` over a real `Uint8Array` returned the empty-input digest for eight bytes of content while the node build threw on the same value, so the package's central claim (the builds never diverge) was false, and in a package whose only in-graph consumer is content addressing a wrong digest is a wrong address, not an error. - `assertBytes` now brand-checks through `%TypedArray%.prototype`'s `length` accessor, captured at module load, which refuses a proxy, ignores a subclass's own `length`, and still accepts a `Buffer`. `jsSha256Into` reads that length once and uses the snapshot. - A detached buffer digested as empty on node and threw V8's own error on the pure-JS build. It now digests as empty everywhere, which is what `node:crypto` does, pinned as deliberate. - The `hostSha256Bytes` digest was aliased, not copied, so a host reusing a scratch buffer could mutate an already-published content address. `.slice()`, as `sha256-node.js` already did off its `Buffer`. - The no-host fallback no longer memoizes, so a digest taken before host-power registration cannot pin pure JS for the process. - The 64-bit bit length is written with `setBigUint64` rather than decomposed into two 32-bit halves. Coverage the prover seat broke and found nothing reddening: `makeSha256Into`'s digest guard, every conditional-export arm (it redirected `xs` at a typo and all 63 tests still passed), and `provideHostToolPowers`' refusal diagnostics, which no daemon test could reach because every Node supervisor supplies all three tools. Each now has a test. CI also generates the daemon bundle now: it is gitignored and was ungated, so a re-introduced `node:` import in the daemon graph reddened nothing. `HostToolPowers` is typed from the implementations rather than `(...args: any[]) => any`, which the locksmith seat flagged as erasing the contract at exactly the seam this refactor introduces. Type-only imports, so no `node:` builtin reaches the XS graph. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The TypeDoc CI gate type-checks JavaScript that `packages/daemon`'s own tsconfig does not (`checkJs: false`), so this file passed the package's `lint:types` and reddened `test`. Now that `HostToolPowers` is typed from the real implementations, calling a tool with no arguments is a type error; the test calls them through a loosely typed view, since what it pins is that a call is refused before any argument is read. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Ready for review at 80521b4, all 25 CI checks green. What it does. Implements the approved What the survey did not anticipate. One open question for you. The design proposed pure-JS (sync) as the browser build with WebCrypto as a separately named future async export, rather than forcing The panel found three real defects, all fixed and each now covered by a test: a wrong-sized or non-buffer one-shot XS host return became a silent wrong content address; Deferred, as the design says: migrating harness |
kriskowal
left a comment
There was a problem hiding this comment.
Please address the one caveat, rebase, retcon, weave, and merge.
| // moment a worker-graph package grows a condition-dependent | ||
| // implementation, since the failure mode is a silently wrong arm | ||
| // rather than an error. | ||
| conditions: new Set(['xs']), |
There was a problem hiding this comment.
We should rename this condition endor.
…n-garden2-5bcdff64 [id:endojs/endo-but-for-bots#903:review:4871446371]
|
Follow-up work is complete at head f6cd2b4fab0b1bbde601ea7a05576cb8e7af76bd.
Verification: @endo/git typecheck passed; its AVA suite passed (3 tests); ESLint completed with 0 errors (9 existing warnings); @endo/daemon typecheck passed; all three Rust/XS bundles regenerated successfully; cargo test --manifest-path rust/endo/xsnap/Cargo.toml crypto_sha256 passed (1 test, 119 filtered); and yarn install --immutable passed. |
Implements the approved design
designs/platform-neutral-hash.md(design PR #824).What this unblocks
node packages/daemon/scripts/bundle-bus-daemon-rust-xs.mjsfailed onllmwith 16 unresolvablenode:imports, sodaemon_bootstrap.jswas never produced, which blockstest:rustand full endor daemon integration (#600). It now produces the bundle.@endo/sha256A small buffer-oriented package:
sha256(bytes) -> Uint8Array(32)andsha256Into(out, bytes, offset). Synchronous, raw digest bytes in and out,TypeError/RangeErrorrather than coercion. Conditional exports:nodesrc/sha256-node.jsnode:cryptocreateHash('sha256')xssrc/sha256-xs.jshostSha256Init/hostSha256UpdateBytes/hostSha256Finishbrowser,defaultsrc/sha256-browser.jsThe XS build composes the host's existing binary-safe incremental triple, so this increment needs no new Rust. It never uses the host's one-shot
hostSha256, which takes a UTF-8 string and so corrupts any input byte above0x7f. It prefers a one-shothostSha256Bytesif the host ever grows one (the design's deferred Rust optimization), and falls back to the pure-JS digest under an XS engine that is not the Endor host.defaultmaps to the pure-JS build so the package is safe under any bundler that sets none of the three conditions.Open question, resolved as the design proposed. WebCrypto's
crypto.subtle.digestis async and cannot back a synchronoussha256(bytes) -> bytes; the only in-graph consumer (makeBlobRefExo) content-addresses inside a synchronous exo factory. So the browser build is pure JS, and a separately named asyncsha256Asyncis left as a future addition rather than built speculatively. Say the word if you want WebCrypto in the browser build instead, which would force the wholeBlobRefconstruction path async.The rest of the bundle blockers
@endo/sha256cleared one of the sixteen. Two more legs, both the same shape, cleared the other fifteen:@endo/exo-gitreachedreadOnlyandwrapBackendthrough the@endo/platform/fs/extendedindex, which also re-exportsmakeNodeFilesystem/makeNodeFsBackendand so dragged innode:fs,node:fs/promises,node:path. It now imports those two modules directly.@endo/gitand@endo/host-spawnerwere statically imported bymanager.js/host.js. Both shell out to a host process and could not run under XS regardless, so they are now injected asDaemonicPowers.hostTools, exactly asbetter-sqlite3already reaches the daemon as an injectedDatabase. Node supervisors supply them fromhost-tool-powers-node.js; the XS supervisor supplies none and gets stand-ins that refuse with a diagnosis, so agitorshellformula under XS reports what is unavailable rather than failing to link.The bundler now passes the
xscondition, which is what the repository's own bundler comments prescribe for steering retention. Without it@endo/sha256would silently take itsdefaultarm (pure JS) instead of binding to the host's Rust-native digest.Verification
All run locally on this branch.
XS daemon bundle generates.
node packages/daemon/scripts/bundle-bus-daemon-rust-xs.mjs->Wrote rust/endo/xsnap/src/daemon_bootstrap.js (2136164 bytes), down from a 16-failureTypeError.node --checkon the output passes. The bundle'shostSha256Initoccurrence count rises from 3 (all inbus-manager-rust-xs-powers.js) to 6, which is the evidence that thexscondition selectedsha256-xs.jsrather than the pure-JSdefaultarm.@endo/sha256: 72 tests pass. All three builds cross-checked againstnode:cryptoover FIPS 180-4 vectors and lengths 0/1/55/56/57/63/64/65/119/120/128/1000; bytes above0x7f; views into a larger buffer;sha256Intooffset, BYOB-into-a-view, and rejection cases. fast-check properties cover the cross-build-agreement claim over generated input (seeded and shrinking, replacing unseededrandomBytes). The xs build is exercised against a stand-in host implementing the Rust contract: the one-shot preference, the no-host fallback, a malformed hex digest, one-shot returns that are a short buffer / long buffer / number / string, and a throwing update that must not leak a host handle.@endo/platform: 309 tests pass (blob-ref,local-blob,snapshot-hashincluded).@endo/chat: 800 pass.@endo/exo-git: 3 pass.@endo/daemon: the suite does not run clean in this sandbox, so it was run against a baseline of the same tree with these changes stashed.test/git.test.js: 6 failures before and after, identical (hostgitconfig and/tmpnoexec).test/shell.test.js+test/mount-snapshot-and-entry.test.js+test/store-controller.test.js: 1 failure before and after, identical (spawn ... EACCES,/tmpnoexec). No regression, and the passing git and shell tests exercise the new injectedhostToolspath end to end.Lint and types:
eslintreports 0 errors acrosspackages/{sha256,daemon,platform,exo-git,chat};tscis clean in each of those five;prettier --checkclean.yarn build:types:genre-ran, so the composite tsconfigs are in sync.TypeDoc:
node node_modules/typedoc/bin/typedocat the root exits 0 with no errors. That gate reddened once on this branch, because it type-checks JavaScript thatpackages/daemon's own tsconfig does not (checkJs: false), so the new daemon test passed the package'slint:typesand still broketest. Fixed in80521b46c; the local reproduction above is what closes that parity gap.Not run here:
yarn --cwd packages/daemon test:rustneeds a builttarget/release/endor, and the full repository suite.Follow-up for #600
Once this merges, #600 needs exactly one thing: rebase on
llmand re-runyarn --cwd packages/daemon test:rust. The bundle generator it was blocked on now succeeds, anddaemon_bootstrap.jsis gitignored and generated, so no artifact needs to move between the branches. No source change is required in #600 for the crypto blocker.Deliberately left for a separate change, as the design says: migrating
native-git-backend.js's twocreateHashsites to@endo/sha256(consistency only, it leaves the XS bundle by exclusion), and the Rust one-shothost_sha256_bytes(this build already prefers it if it appears).Panel rounds
The scripted jury panel reviewed this twice and both rounds found real defects, fixed in
1ae5f99a1and75e2d35fb.Round 1 (at
3a7f81bac): a wrong-sized or non-buffer one-shot XS host return became a silent wrong content address; the streaming triple leaked a host handle when an update threw; the pure-JS padded length used a signed 32-bit shift that diverges from the node build above 2 GiB. It also blocked on missing changesets and onrust/endo/README.mdstill describing this bundle as failing while the new code cited it as the durable record.Round 2 (at
f3499b3f3): three seats independently ran the same attack.assertBytesusedinstanceof, which aProxyover a realUint8Arraysatisfies, and the pure-JS build readbytes.lengthfour separate times, so a proxy answering different lengths returned the empty-input digest for eight bytes of content while the node build threw on the same value. It now brand-checks through%TypedArray%.prototype'slengthaccessor and reads the length once. A detached buffer likewise diverged (node: empty digest, pure JS: V8's own throw) and now digests as empty everywhere, matching node. ThehostSha256Bytesdigest was aliased rather than copied, and the no-host fallback memoized so an early digest could pin pure JS for the process; both fixed.The prover seat broke three things that reddened nothing and each now has a test:
makeSha256Into's digest guard, every conditional-export arm (it pointedxsat a typo and the whole suite still passed), andprovideHostToolPowers' refusal diagnostics. CI now also generates the daemon bundle, which was ungated because the artifact is gitignored.Remaining juror suggestions are preferences and follow-ups rather than defects, and are listed in the panel record rather than acted on here.
Draft for review.