Skip to content

jco: host hardening — internals coupling, signature-key extractability, JWK exposure, scalar validation #28

Closed

Description

@lann
  • jco internals coupling. webcrypto.js throws bare { tag: ... } literals throughout and sniffs three stream shapes in collectByteStream (jco-impl/webcrypto.js:580-615); both depend on undocumented jco conventions. Isolate behind small helpers and note the jco version they were validated against. (The same convention-sniffing appears in examples/jco-demo/src/run.mjs:15-22.) (Migrated from TODO.md.)
  • README overclaims platform enforcement of extractability. README.md:27-28 says "extractable: false keys refuse export-key (on the jco host the platform CryptoKey itself enforces this)". True for HMAC/AES keys — false for signature keys: Ed25519/ECDSA private keys are deliberately imported/generated platform-extractable (jco-impl/webcrypto.js:845, 855, 909-915, 958-963) so the public half and export can be derived, and the WIT gate is a JS-level boolean (webcrypto.js:763-777). Defensible design, weaker guarantee than documented. Fix the README; document the actual guarantee in the file.
  • JWK d-string exposure. SigningKey.exportKey materializes the private scalar as a JWK d string plus atob (webcrypto.js:773-788) — immortal, unscrubable JS strings holding key material. Document the exposure; consider alternatives.
  • ECDSA scalar-range validation is delegated and untested. importEcdsaSigningKey wraps the raw scalar in hand-rolled PKCS#8 and trusts the platform to reject zero/out-of-range scalars (webcrypto.js:893-924); the Rust implementations reject definitively via SigningKey::from_slice. No conformance probe pins the out-of-range case on a jco target (the signing-guest runs only under wasmtime).

First item migrated from TODO.md; the rest from the 2026-07 whole-repo review.

Activity

  1. added
    bugSomething isn't working
    on Jul 26, 2026
  2. lann commented on Jul 27, 2026

    @lann
    CollaboratorAuthor

    Data point from the first Firefox run of the conformance corpus (#52's CONFORMANCE_ENGINE=firefox experiment): Firefox 153 (Playwright build, JSPI pref) executes the entire shared corpus — 6,745 JSPI-driven cases in Web Workers — but the signing corpus fails with an OperationError escaping derivePublicKey's JWK export/re-import dance in jco-impl/webcrypto.js, which is unwrapped: the platform quirk surfaces as an uncaught page error (pre-#52, an infinite adapter hang) instead of a WIT invalid-key. Wrapping that path — this issue's first checklist item's territory — would turn the Firefox gap into a proper, testable decline and is the one blocker between Firefox and a full four-target-plus run.

  3. lann commented on Jul 27, 2026

    @lann
    CollaboratorAuthor

    PR #53 addresses the first checklist item (internals coupling): error lifting isolated behind per-case constructors over a single witError, stream ingestion named as the second convention site (already centralized in collectByteStream), both documented and version-anchored in the file header — validated against jco 1.26.1 / jco-transpile 0.5.2, lockfiles bumped to match in both npm consumers. The jco-demo driver's result-unwrapping convention got the same treatment (unwrapResult).

    The fourth item (ECDSA scalar-range validation delegated and untested on jco) appears already resolved on main: the signing guest now runs on jco-node (run:node-signing) and probe/ecdsa-sign-invalid-scalar — wrong lengths, zero, all-ones ≥ order — passes there, so the platform's PKCS#8-path rejection is empirically pinned rather than trusted. Ticking it.

    Remaining: items 2 (README extractability overclaim) and 3 (JWK d-string exposure documentation).

  4. lann commented on Jul 27, 2026

    @lann
    CollaboratorAuthor

    Post-#53 status for the Firefox data point above: re-ran CONFORMANCE_ENGINE=firefox on current main (rebased #52). #53's isolation closed the convention couplings, but the specific gap here remains — derivePublicKey (jco-impl/webcrypto.js:1038) is still outside any try/catch at both call sites (:1168 Ed25519, :1236 ECDSA), and Firefox 153 still surfaces its OperationError as an uncaught page error in the signing corpus (now caught by #52's watchdog instead of hanging). Shared corpus: all 6,226 post-#51 cases pass on Firefox. Wrapping that one path — probably as unsupported (the platform declines deriving the public half from a private-only import; Chromium serves it, Firefox doesn't) — is the last blocker to a clean jco-firefox column.

  5. lann commented on Jul 27, 2026

    @lann
    CollaboratorAuthor

    Full Firefox analysis (isolated with a step-by-step WebCrypto probe outside jco, then confirmed against the corpus; fixes pushed to #52):

    Root cause of the OperationError: engines diverge on ECDSA keys imported from private-only PKCS#8 (the raw-scalar path). Chromium computes the missing public point at import — exportKey("jwk") carries x/y. Firefox 153 stores only what was imported: importKey succeeds, sign works (64-byte signatures), exportKey("pkcs8") round-trips — but exportKey("jwk") throws OperationError because the public point was never computed. Ed25519 is unaffected (its JWK export derives x fine). Since signing-key.verifying-key() is infallible by contract, an import whose public half can't be derived can't serve the resource: derivePublicKey now lifts the failure to error.unsupported at minting — closing the last unwrapped platform call in a minting path (checklist item 1's remaining instance).

    Second find, more serious: with the hang fixed, the corpus revealed Firefox accepts invalid ECDSA signatures — all 22 of its Wycheproof verification failures were SignatureSize vectors; it zero-pads short P1363 halves and accepted a 2-byte signature (r=5, s=1) as valid P-256. The WIT contract (and Chromium) fix the width at 64/96. Fixed host-side with a length check in verify before delegating — public data, strictly monotone, the same pattern as #37's Ed25519 predicates.

    Firefox status after both fixes: the entire shared corpus is green (6,226 cases; ChaCha + deterministic-ECDSA skips as declared). The only remaining divergence is the four signing-corpus import probes, now failing cleanly as unsupported (generate-key works — the gap is import-only). A future jco-firefox target therefore needs finer granularity than the current ecdsa-sign corpus-level feature (import works nowhere, generate works fine); one for the targets.toml design if that column ever becomes real. Chromium and Node outcomes are byte-identical before/after.

  6. lann commented on Jul 27, 2026

    @lann
    CollaboratorAuthor

    Compliance verdict on the private-only PKCS#8 question (checked against the specs, not folklore; doc updated in #52):

    • RFC 5915 §3: publicKey is syntactically OPTIONAL, omission is explicitly permitted ("can be omitted when the public key has been distributed via another mechanism"), and the RFC notes the public key "can always be recomputed" from the private one. There is an encoder-side SHOULD to include it — which this host cannot honor, since computing Q = d·G in dependency-free JS is the very thing being delegated to the platform.
    • WebCrypto ECDSA importKey("pkcs8"): parses ECPrivateKey per RFC 5915 and errors only on parse failure / curve mismatch / invalid private value — the private-only form is valid input. Both engines accept it.
    • WebCrypto ECDSA exportKey("jwk"): sets x and y unconditionally for P-256/384/521, with no "if available" branch and no defined failure path — a conforming engine must be able to produce the public point for any EC private CryptoKey it holds.

    So: the host's encoding is compliant (modulo an encoder SHOULD whose rationale the RFC itself undercuts); Firefox's OperationError at export is the non-conforming step; Chromium's derive-at-import is conforming behavior. The unsupported lift stands as the right floor regardless of fault. If a jco-firefox column ever becomes real, the compliant-side workaround sketched earlier still applies (platform ECDH deriveBits against the generator yields the x-coordinate of d·G with the scalar multiplication inside NSS; JS computes y by modular square root of public values and disambiguates ±y by test-verification — no secret ever touches variable-time JS), and a Firefox bug report would be well-founded.

  7. lann commented on Jul 27, 2026

    @lann
    CollaboratorAuthor

    Correction and resolution, per w3c/webcrypto#356 (thanks for the pointer): the private-only PKCS#8 import area is a recognized spec gap, not a settled algorithm — the issue's survey had Chromium/Firefox/WebKit/Deno/Workerd rejecting the import outright in 2023, with only Node/Bun accepting; current Chromium accepting-and-deriving is itself drifted behavior. I'm retracting the earlier "Firefox is non-conforming" verdict — my unconditional-export reading was one strict interpretation of a spec upstream considers unspecified.

    Consequence, pushed to #52: the conformance corpus stops asserting private-only import entirely. The signing probes are generate-based now (with a new export-point → ecdsa-verify re-import leg), invalid-scalar keeps only host-side-validated length cases, and the RFC 6979 deterministic probe is removed — its known answers (exact signature bytes, scalar export identity, derived public point) plus scalar-range rejection are pinned in webcrypto-impl-core unit tests, portably covering both Rust implementations. The deterministic-ecdsa feature tags nothing anymore and is retired from the guests and targets.toml.

    Net effect: Firefox 153 now runs both corpora fully green under CONFORMANCE_ENGINE=firefox, and the gating targets are unchanged (24,916 results, 0 failed, 0 transport problems across wasmtime/composed/jco-node/jco-browser). The jco host keeps import-signing-key best-effort — it works where the platform cooperates and declines as unsupported where it doesn't, which the WIT permits — but nothing in the gate depends on unspecified platform behavior anymore.

  8. lann commented on Jul 27, 2026

    @lann
    CollaboratorAuthor

    Correction to the error mapping (review feedback on #52): derivePublicKey failures now lift to error.other, not unsupported. The unsupported mapping asserted a semantic condition ('these algorithm parameters aren't served here — compose another provider') that a failure at this point cannot back: under lazy engine validation the same opaque OperationError can be genuinely invalid material surfacing late (Firefox imports an out-of-range scalar without complaint), a missing engine capability, or a transient platform fault — indistinguishable from the host. other is the taxonomy's designated carrier for operational platform failures and makes no claim callers might branch on. Both PRs re-verified (jco-node green; Firefox both suites green on the rebased #55).

  9. lann commented on Jul 28, 2026

    @lann
    CollaboratorAuthor

    PR #59 resolves the second checklist item — at its root rather than by fixing the wording.

    The README's claim was weak because signing-key.verifying-key forced it to be: signature private keys had to be imported and generated platform-extractable: true so derivePublicKey could round-trip the JWK, which left the WIT gate as a JS-level boolean. #59 removes that method from the WIT — a signing key cannot yield its public half at all, since a provider may legitimately not have it (unspecified private-only import behavior per w3c/webcrypto#356; keystore-resident non-extractable keys that sign but yield nothing else). generate-key returns tuple<signing-key, verifying-key> instead, which every platform can serve, and importers use import-verifying-key.

    With no derive, the WIT extractable flag is passed straight through to importKey/generateKey, so the platform CryptoKey enforces non-extractability for signature keys exactly as it already did for HMAC/AES. The README statement is now simply true. derivePublicKey and its error.other lift are deleted outright.

    Also ticking items 1 and 4, which the comments above established were already resolved on main (#53 for the convention couplings; the generate-based signing probes plus impl-core's unit tests for scalar-range validation) but whose checkboxes were never updated.

    Remaining: item 3, the JWK d-string exposure — deliberately out of scope for #59. Worth noting that its mint-time half is gone with derivePublicKey, which exported a d-bearing JWK on every signing-key mint whether or not the caller ever exported. What remains is the per-export string in SigningKey.exportKey, addressable on its own by exporting via pkcs8 and parsing the DER (Ed25519 is fixed-layout — the host already writes that exact prefix at import; ECDSA needs a short dependency-free DER walk), leaving no key material transiting a JS string.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions