Skip to content

Rdubois zy0nreview - #4

Open
rdubois-crypto wants to merge 20 commits into
railgun-reloaded:mainfrom
ZKNoxHQ:rdubois-Zy0nreview
Open

rdubois-crypto wants to merge 20 commits into
railgun-reloaded:mainfrom
ZKNoxHQ:rdubois-Zy0nreview

Conversation

@rdubois-crypto

Copy link
Copy Markdown

Fix Blake512b->blake512 issue #2

mattgle and others added 19 commits June 25, 2026 10:54
…oseidon bounds

- babyfrost: compute deriveInterpolatingValue in the scalar field
  (modOrder/invModOrder) instead of truncating integer division, which
  produced wrong shares for non-contiguous signer subsets; add a 3-of-5
  regression test that signs with the subset {1,2,4}
- module-index: use toReversed() so poseidon() no longer mutates caller input
- poseidon wrapper: accept up to 16 inputs to match poseidon-lite and the
  error message; drop dead alias/comments; @ts-ignore -> @ts-expect-error
- dkg manager: wrap share decryption in a labeled error naming the dealer
- replace file-wide jsdoc/require-jsdoc disables with proper JSDoc
- fix typos: recievePartials -> receivePartials, particpantID -> participantID
- tighten types (any -> Uint8Array / bigint[]), drop redundant null check,
  remove stale TODOs and commented-out code
DKGManager.getDecryptedShares decrypted each dealer share but never
checked it against that dealer's Feldman commitments, so a malicious or
buggy dealer could fold an off-polynomial share into the aggregate
signing key undetected. AES-GCM AAD only authenticates transport against
the commitment digest, not polynomial consistency.

Call verifyFeldmanShare for every dealer and throw on mismatch. Add a
regression test that forges an off-polynomial share under a matching AAD
digest to isolate the Feldman check from the AEAD tag.
deriveViewKeyFromPK is intentionally a deterministic function of the
broadcast dealer commitments so every share-holder derives the same
shareable viewing key for engine view access. Document that this value
is therefore not secret against anyone who observes those commitments.
finalize() only checked local signature shares (verifySignatureShare
needs each signer's secret share, held only locally), so a forged or
malformed remote partial produced an invalid aggregate returned without
error. Verify the aggregate via eddsaBuild.verifyPoseidon before
returning and throw on failure. Per-signer attribution would require
exchanging public verification shares and is documented as out of scope.

round1() also now clears partialsById so a fresh round cannot aggregate
stale partials against newly generated nonces; remoteSigners is left to
resetRoundState() because the exchange flow interleaves round1() and
addRemoteSigner() across peers.

Also fix the misspelled receivePartials method name.
Both copies of deriveInterpolatingValue (BabyFROST and TrustedDKG) rejected
a missing identifier with `!found`, which also rejects a legitimate
identifier of 0. Guard `found === undefined` instead.

Also adds manager-level regression coverage for non-consecutive signer
subsets ([1,2,5], [2,4,5], [1,3,5]), complementing the existing
TrustedDKG-level {1,2,4} case, and corrects the receivePartials spelling
in the README.
combineGroupPubkeyFromCommitments, verifyAllCommitmentsSubgroup, and
verifyFeldmanShare tested subgroup membership with ScalarMult(P, order).
ScalarMult reduces its scalar mod order, so order became 0 and
mulPointEscalar(P, 0) is the identity for every point — the check always
passed, accepting commitment points outside the prime-order subgroup
(including via the verifyFeldmanShare path the manager now relies on).

Call mulPointEscalar(P, order) directly, matching DeserializeElement.
Found while writing adversarial tests; regression coverage added in
test/trusted-dkg-adversarial.test.ts.
…managers

Negative and guard-path tests for curve serialization (identity, length,
non-subgroup, scalar range, Montgomery degenerate inputs), shareable and
multisig key decode validation and round-trips, and DKG/signing manager
input + flow-order guards. Suite 30 -> 97 tests; statements 88.5% ->
96.6%, branches 70.8% -> 83.6%.
The DKG flow state was tracked imperatively via `this.state = ...`
assignments scattered across six methods, and the "is the roster fully
covered?" completeness check was duplicated in three places. The single
linear enum also conflated two independent dimensions: local pipeline
progress (what this participant produced) and collection progress (which
peer commitments / encrypted shares have arrived).

Separate the two: local progress is a small DKGSteps flag record, and
collection progress is queried from the commitment / encrypted-share maps
via one rosterCovers() helper. getState() now derives the linear
DKGFlowState from both, so collecting the final artifact advances the
reported state with no separate bookkeeping. Guards call the derived
state, so behavior (and error strings) are unchanged.

Also:
- progress() exposes { state, awaitingCommitments, awaitingEncryptedShares }
  so a driver/UI can see which dealers are still outstanding.
- commitmentRound() no longer self-adds its own commitment; the caller
  feeds it back through addParticipantCommitments() like any peer's, one
  uniform collection path (matches the documented flow).
- toJSON()/static fromJSON() give JSON-safe, versioned snapshots for
  pause/resume across process restarts. Snapshots carry secret material.
- removed a dead validation branch in addEncryptedShares.

New test covers a mid-flow JSON snapshot/restore. Docs updated.
readyToFinalize() returned true at `available >= threshold`, but
finalize() interpolates over the entire commitment list and requires a
partial from every participant in it. When the participating set exceeded
threshold (e.g. 5 signers with t=3), readyToFinalize() reported ready
after only `threshold` partials while finalize() threw. Existing tests
masked this by always using exactly `threshold` signers. readyToFinalize()
now returns getMissingPartials().length === 0.

Also harden round/orchestration handling:
- Reject a second round1() without an intervening resetRoundState():
  it regenerates local nonces while peers still hold the old commitments,
  so any partial produced is invalid. Tracked via round1CallsSinceReset
  and checked in sign()/finalize(). (An earlier per-remote epoch approach
  was wrong — the interleaved exchange lets a node receive all peer
  commitments before its own round1(), so remote-epoch staleness
  false-positives on the normal flow.)
- addSigner() rejects duplicate identifiers (would corrupt aggregation).
- sign() guards empty localBindings with a clear error.
- Removed the unused sessions array / createSession() / SigningSession
  type and the stale todo.
- hasId() simplified; sign()/finalize() params renamed msgHash -> message
  (they were never hashes; primitives hash internally).

New tests cover set-exceeds-threshold readiness, the restart guard, and
addSigner dedup. Docs updated.
Per-round signing state (local nonces, collected peer commitments,
partials) lived in flat fields on the manager, so a single manager could
drive only one round at a time and restart relied on the caller
remembering resetRoundState(). Move that state into a SigningSession,
keyed by id on the manager, so independent signings are isolated and one
device can sign multiple messages concurrently.

- SigningSession owns localBindings/remoteSigners/partialsById and the
  full round1 -> sign -> finalize flow; the manager keeps durable identity
  (signers, group public key, threshold) and a Map of sessions.
- startSession()/session()/hasSession()/endSession() manage lifetimes.
- The flat methods (round1/sign/finalize/...) are thin delegates to a
  lazily-created 'default' session, so the single-session API and all
  existing callers are unchanged. partialsById/remoteSigners/localBindings
  remain accessible as getters over the default session.
- A session is single-commit: round1() throws if already committed (the
  clean replacement for the round1CallsSinceReset restart guard); reset()
  restarts it.
- finalize(message) now asserts message matches what sign(message) signed.

Tests updated for the new restart semantics; added concurrent two-session,
re-commit-rejection, and message-mismatch coverage. Suite 101 -> 103.
Bring the signing manager to parity with the DKG manager's pause/resume
support. toJSON()/static fromJSON() serialize the manager and every live
session to a versioned, JSON-safe snapshot (bigints hex-encoded, points as
[x, y] hex pairs) and rebuild them, so a signing can survive a process
restart mid-flow.

Crucially the snapshot captures each session's local nonces, so a restored
manager produces partials that still match the commitments peers already
hold. SigningSession gains toSnapshot()/fromSnapshot(); the manager
serializes its session map.

Snapshots contain secret material (signer shares and local nonces).

New test snapshots after the commitment exchange, rebuilds from parsed
JSON, and completes signing to a verifying signature. Suite 103 -> 104.
Docs updated with a pause/resume section.
Clarify that the group public key and viewing key are deterministic in
the input secrets, while individual signing shares are freshly random on
every run by design (the higher-order polynomial coefficients come from a
CSPRNG). Reproducible shares would be a replay/leakage footgun; recovery
should use toJSON()/fromJSON() snapshots, not re-derivation. Pre-empts
mistaking the random shares for a bug.
Identifier 0 is the point at which the sharing polynomial evaluates to the
shared secret itself, so it can never be a participant identifier. Both
copies of deriveInterpolatingValue relied on a falsy `!found` check to
reject it by accident; switching that guard to `found === undefined`
removed the rejection without putting anything in its place, and neither
addSigner nor addRemoteSigner validated the identifier.

Reject non-positive identifiers explicitly:

- deriveInterpolatingValue (BabyFROST and TrustedDKG) rejects a
  non-positive x_i and any non-positive id in the signer set, while still
  rejecting an identifier absent from the set.
- addSigner rejects non-integer and non-positive ids.
- addRemoteSigner rejects a peer announcing identifier 0, which would
  otherwise enter the commitment list and shift every honest signer's
  Lagrange coefficients.

Adds coverage for each guard, and for round1() discarding partials that
arrived before this node committed (receivePartials has no ordering guard,
and such a partial cannot have been computed against a commitment list
containing ours). Suite 107 -> 118.
commitmentRound() no longer stores its own commitments; the caller feeds
them back through addParticipantCommitments() like any peer's. A caller
written against the older self-adding behaviour does not fail at the call
site — it silently never reaches commitments-collected because it is
waiting on itself, and getEncryptedShares reports only a generic state
error.

Detect that case and throw naming the participant's own id and the call
required to fix it. Adds a regression test driving the old caller pattern
and a migration section to the DKG manager docs.
The resumable snapshot serialized each committed session's local nonces so
a restored manager could still produce partials matching the commitments
peers hold. That is what makes it dangerous to restore twice: the same
(hidingNonce, bindingNonce) pair then signs different messages under
different challenges, and three such partials give three independent
equations in d_i, e_i and s_i — enough to recover the signer's secret
share. Nothing distinguished that payload from a harmless one, and the
docs warned only about confidentiality, not use count.

Separate the two by kind:

- toJSON()/fromJSON() stay the default and carry no nonces. Committed
  sessions are dropped and their ids reported in omittedSessions, so the
  common pause/resume path cannot reuse a nonce however often it runs.
- toMidRoundJSON()/fromMidRoundJSON() carry the nonces and are documented
  as single use, so resuming a signing already past round 1 has to be
  spelled out at the call site.

Each entry point rejects the other's payload, naming the one to use.
Snapshot version 1 -> 2 for the added discriminant. Docs rewritten with
the nonce-reuse hazard stated explicitly. Suite 119 -> 122.
The FROST and DKG contexts both advertise FROST-EDBABYJUJUB-BLAKE512-v1, but
RFC9591Hasher defaulted to BLAKE2b-512. BLAKE-512 (the original BLAKE, SHA-3
finalist) and BLAKE2b-512 share a digest length and nothing else, so every
H1-H6 output, and therefore every binding factor and challenge, differed from
what the ciphersuite string promises. An independent implementation written
against the label produces partial signatures that aggregate into an invalid
signature, with no diagnostic beyond a failed verification.

curve.ts already imports the correct function from '@noble/hashes/blake1.js'
and uses it for EdDSA-Poseidon key derivation, so FROST and EdDSA were running
on two different base hashes within one library. Point the hasher at the same
import: no new dependency, and the two paths now agree.

Regenerate the frozen H1/H3/H4/H5 vectors in hashes.test.ts, which pinned the
BLAKE2b outputs. Add test/blake512.test.ts with digest-level known answers, so
that the base hash is pinned directly rather than only through the algebraic
FROST tests, which pass under any 64-byte digest.
Round-1 nonce commitments arrive as already-decoded Point<bigint> pairs and
never pass through DeserializeElement, so nothing checked them: not curve
membership, not subgroup membership, not the identity. An off-curve pair was
accepted by addRemoteSigner, encoded into the binding-factor preimage, and
folded into the group commitment, where the Edwards addition law is undefined.

Add RailJubCurvePoint.assertValidElement, mirroring the checks
DeserializeElement already performs on the wire encoding, and call it from the
three places a peer point enters: SigningSession.addRemoteSigner,
snapshotToCommitment on the restore path, and encodeGroupCommitmentList for
consumers driving BabyFROST without the manager.

Aggregate verification already turned a malformed peer into a failed round, but
without attribution: the honest participants could not tell whom to exclude.
Validating at ingestion names the offending peer instead.

computeGroupCommitment is left unguarded because computeBindingFactors, and
therefore encodeGroupCommitmentList, always precedes it; duplicating the check
would cost two scalar multiplications per signer per signature.
@rdubois-crypto

Copy link
Copy Markdown
Author

6cba9e fix #3

@zk-kit/baby-jubjub pulled @zk-kit/utils, which pulled the buffer polyfill
along with base64-js and ieee754. The package therefore shipped a Node-only
dependency chain for what amounts to affine twisted Edwards arithmetic over
the BN254 scalar field. After this change src/ contains no reference to
Buffer at all, so the bundle runs unmodified in a browser or under wasm.

src/babyjubjub.ts provides the same surface -- Point, Base8, r, order,
subOrder, a, d, Fr, addPoint, mulPointEscalar, inCurve, packPoint,
unpackPoint -- and is byte-for-byte compatible with the implementation it
replaces, verified against 400 vectors covering scalar multiplication,
addition, curve membership, compression round-trips, scalars at and above the
subgroup order, points outside the prime-order subgroup, and the field
comparison semantics that the compression sign bit depends on.

@noble/curves was not used for the arithmetic. It rejects scalars outside
[0, n), and DeserializeElement multiplies an untrusted point by the subgroup
order precisely to test membership; reducing that scalar would make the check
vacuous. verifyPoseidon has the same requirement for hm * 8. noble is used
instead as a validation anchor in the new test, which pins the curve
parameters -- including Point.BASE, which noble has changed historically --
and cross-checks arithmetic on every run.

packPoint keeps circomlib's sign convention (x greater than (r-1)/2), which
RAILGUN addresses and Circom witnesses expect, while SerializeElement keeps
RFC 8032 parity for the FROST transcript. The two agree about half the time;
a test asserts they still differ, so the encodings are not unified by mistake.

Also repoint the signing-manager doc example at src/bytes, which already
superseded the @zk-kit/utils import it still showed.

No test vector changed. The 144 existing tests still pass, plus 16 new ones.
@rdubois-crypto

Copy link
Copy Markdown
Author

21c5021 remove dependency to zkkit.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants